From 2af5f85dc6f1eb8db61d9ea177dcc59e884e154e Mon Sep 17 00:00:00 2001 From: futpib Date: Sat, 17 Nov 2018 00:25:55 +0300 Subject: [PATCH] Hotkeys --- components/cards/index.js | 134 +++++----- components/graph/index.js | 358 +++++++++++++++++++-------- components/graph/satellites-graph.js | 8 +- components/hot-keys/index.js | 75 ++++++ components/preferences/index.js | 252 ++++++++++--------- index.css | 4 + package.json | 1 + renderer.js | 11 +- yarn.lock | 28 ++- 9 files changed, 585 insertions(+), 286 deletions(-) create mode 100644 components/hot-keys/index.js diff --git a/components/cards/index.js b/components/cards/index.js index 113b61f..dfc46ad 100644 --- a/components/cards/index.js +++ b/components/cards/index.js @@ -6,79 +6,95 @@ const { sortBy, } = require('ramda'); +const React = require('react'); + const r = require('r-dom'); const { connect } = require('react-redux'); const { bindActionCreators } = require('redux'); -const { withStateHandlers } = require('recompose'); - const { pulse: pulseActions } = require('../../actions'); const Button = require('../button'); const Label = require('../label'); const Select = require('../select'); -const Preferences = withStateHandlers( - { - open: false, - }, - { - toggle: ({ open }) => () => ({ open: !open }), - }, -)(({ open, toggle, ...props }) => r.div({ - classSet: { - panel: true, - cards: true, - open, - }, -}, open ? [ - r.div([ - r(Button, { - style: { width: '100%' }, - autoFocus: true, - onClick: toggle, - }, 'Close'), - ]), +class Cards extends React.Component { + constructor(props) { + super(props); - r.hr(), + this.state = { + open: false, + }; + } - ...map(card => r(Label, { - title: card.name, - }, [ - r(Label, [ - path([ 'properties', 'device', 'description' ], card), - ]), + toggle() { + this.setState({ open: !this.state.open }); + } - r(Select, { - options: sortBy(p => -p.priority, card.profiles), - optionValue: p => p.name, - optionText: p => [ - p.description, - !p.available && '(unavailable)', - ] - .filter(Boolean) - .join(' '), - value: card.activeProfileName, - onChange: e => { - props.actions.setCardProfile(card.index, e.target.value); + close() { + this.setState({ open: false }); + } + + render() { + const { open } = this.state; + const toggle = this.toggle.bind(this); + + return r.div({ + classSet: { + panel: true, + cards: true, + open, }, - }), - ]), values(props.cards)), + }, open ? [ + r.div([ + r(Button, { + style: { width: '100%' }, + autoFocus: true, + onClick: toggle, + }, 'Close'), + ]), - props.preferences.showDebugInfo && r.pre({ - style: { - fontSize: '0.75em', - }, - }, [ - JSON.stringify(props, null, 2), - ]), -] : [ - r(Button, { - autoFocus: true, - onClick: toggle, - }, 'Cards'), -])); + r.hr(), + + ...map(card => r(Label, { + title: card.name, + }, [ + r(Label, [ + path([ 'properties', 'device', 'description' ], card), + ]), + + r(Select, { + options: sortBy(p => -p.priority, card.profiles), + optionValue: p => p.name, + optionText: p => [ + p.description, + !p.available && '(unavailable)', + ] + .filter(Boolean) + .join(' '), + value: card.activeProfileName, + onChange: e => { + this.props.actions.setCardProfile(card.index, e.target.value); + }, + }), + ]), values(this.props.cards)), + + this.props.preferences.showDebugInfo && r.pre({ + style: { + fontSize: '0.75em', + }, + }, [ + JSON.stringify(this.props, null, 2), + ]), + ] : [ + r(Button, { + autoFocus: true, + onClick: toggle, + }, 'Cards'), + ]); + } +} module.exports = connect( state => ({ @@ -88,4 +104,6 @@ module.exports = connect( dispatch => ({ actions: bindActionCreators(pulseActions, dispatch), }), -)(Preferences); + null, + { withRef: true }, +)(Cards); diff --git a/components/graph/index.js b/components/graph/index.js index d47788b..d2f02e6 100644 --- a/components/graph/index.js +++ b/components/graph/index.js @@ -1,16 +1,23 @@ +/* global document */ const { - map, - values, - flatten, - path, - filter, - forEach, - merge, - repeat, - defaultTo, - prop, all, + bind, + compose, + defaultTo, + filter, + find, + flatten, + forEach, + keys, + map, + merge, + path, + pick, + prop, + repeat, + sortBy, + values, } = require('ramda'); const React = require('react'); @@ -20,6 +27,8 @@ const r = require('r-dom'); const { connect } = require('react-redux'); const { bindActionCreators } = require('redux'); +const { HotKeys } = require('react-hotkeys'); + const d = require('../../utils/d'); const memoize = require('../../utils/memoize'); @@ -43,6 +52,8 @@ const { size } = require('../../constants/view'); const VolumeSlider = require('../../components/volume-slider'); +const { keyMap } = require('../hot-keys'); + const { GraphView, } = require('./satellites-graph'); @@ -53,6 +64,49 @@ const { const LayoutEngine = require('./layout-engine'); +const leftOf = (x, xs) => { + const i = ((xs.indexOf(x) + xs.length - 1) % xs.length); + return xs[i]; +}; + +const rightOf = (x, xs) => { + const i = ((xs.indexOf(x) + 1) % xs.length); + return xs[i]; +}; + +const selectionObjectTypes = { + order: [ + 'source', + 'sourceOutput', + 'client|module', + 'sinkInput', + 'sink', + ], + + left(type) { + return leftOf(type, this.order); + }, + + right(type) { + return rightOf(type, this.order); + }, + + fromPulseType(type) { + if (type === 'client' || type === 'module') { + return 'client|module'; + } + return type; + }, + + toPulsePredicate(type) { + type = this.fromPulseType(type); + if (type === 'client|module') { + return o => (o.type === 'client' || o.type === 'module'); + } + return o => o.type === type; + }, +}; + const dgoToPai = new WeakMap(); const key = pao => `${pao.type}-${pao.index}`; @@ -97,14 +151,6 @@ const getPaiIcon = memoize(pai => { path([ 'properties', 'device', 'icon_name' ], pai); }); -const graphConfig = { - nodeTypes: {}, - - nodeSubtypes: {}, - - edgeTypes: {}, -}; - const s2 = size / 2; const Sink = () => r.path({ @@ -485,6 +531,82 @@ class Graph extends React.Component { }); } + static getDerivedStateFromProps(props) { + let edges = map(paoToEdge, flatten(map(values, [ + props.objects.sinkInputs, + props.objects.sourceOutputs, + props.derivations.monitorSources, + ]))); + + const connectedNodeKeys = new Set(); + edges.forEach(edge => { + connectedNodeKeys.add(edge.source); + connectedNodeKeys.add(edge.target); + }); + + const filteredNodeKeys = new Set(); + + const nodes = filter(node => { + if ((props.preferences.hideDisconnectedClients && node.type === 'client') || + (props.preferences.hideDisconnectedModules && node.type === 'module') || + (props.preferences.hideDisconnectedSources && node.type === 'source') || + (props.preferences.hideDisconnectedSinks && node.type === 'sink') + ) { + if (!connectedNodeKeys.has(node.id)) { + return false; + } + } + + const pai = dgoToPai.get(node); + if (pai) { + if (props.preferences.hideMonitors && + pai.properties.device && + pai.properties.device.class === 'monitor' + ) { + return false; + } + + if (props.preferences.hidePulseaudioApps) { + const binary = path([ 'properties', 'application', 'process', 'binary' ], pai) || ''; + const name = path([ 'properties', 'application', 'name' ], pai) || ''; + if (binary.startsWith('pavucontrol') || + binary.startsWith('kmix') || + name === 'paclient.js' + ) { + return false; + } + } + } + + filteredNodeKeys.add(node.id); + return true; + }, map(paoToNode, flatten(map(values, [ + props.objects.sinks, + props.objects.sources, + props.objects.clients, + props.objects.modules, + ])))); + + edges = filter(edge => { + if (props.preferences.hideMonitorSourceEdges && edge.type === 'monitorSource') { + return false; + } + return filteredNodeKeys.has(edge.source) && filteredNodeKeys.has(edge.target); + }, edges); + + nodes.forEach(node => { + const pai = getPaiByTypeAndIndex(node.type, node.index)({ pulse: props }); + dgoToPai.set(node, pai); + }); + + edges.forEach(edge => { + const pai = getPaiByTypeAndIndex(edge.type, edge.index)({ pulse: props }); + dgoToPai.set(edge, pai); + }); + + return { nodes, edges }; + } + shouldComponentUpdate(nextProps, nextState) { return !( (nextProps.objects === this.props.objects) && @@ -497,6 +619,9 @@ class Graph extends React.Component { componentDidMount() { this.getIconPath('audio-volume-muted'); + + this.graphViewElement = document.querySelector('#graph .view-wrapper'); + this.graphViewElement.setAttribute('tabindex', '-1'); } componentDidUpdate() { @@ -550,15 +675,11 @@ class Graph extends React.Component { const pai = dgoToPai.get(data); if (pai && event.button === 1) { if (pai.type === 'sink' || - pai.type === 'source' + pai.type === 'source' || + pai.type === 'client' || + pai.type === 'module' ) { this.toggleMute(pai); - } else if (pai.type === 'client') { - const sinkInputs = getClientSinkInputs(pai)({ pulse: this.props }); - this.toggleAllMute(sinkInputs); - } else if (pai.type === 'module') { - const sinkInputs = getModuleSinkInputs(pai)({ pulse: this.props }); - this.toggleAllMute(sinkInputs); } } } @@ -616,85 +737,118 @@ class Graph extends React.Component { this.props.setSinkMute(pai.index, muted); } else if (pai.type === 'source') { this.props.setSourceMute(pai.index, muted); + } else if (pai.type === 'client') { + const sinkInputs = getClientSinkInputs(pai)({ pulse: this.props }); + this.toggleAllMute(sinkInputs); + } else if (pai.type === 'module') { + const sinkInputs = getModuleSinkInputs(pai)({ pulse: this.props }); + this.toggleAllMute(sinkInputs); } } + focus() { + this.graphViewElement.focus(); + } + + deselect() { + this.setState({ selected: null }); + } + + hotKeyMute() { + if (!this.state.selected) { + return; + } + + const pai = dgoToPai.get(this.state.selected); + + if (!pai) { + return; + } + + this.toggleMute(pai); + } + + _findNextObjectForSelection(object, direction) { + const { type } = object || { type: 'client' }; + const predicate = selectionObjectTypes.toPulsePredicate(type); + const candidates = compose( + sortBy(prop('index')), + filter(predicate), + )(this.state.nodes.concat(this.state.edges)); + return (direction === 'up' ? leftOf : rightOf)(object, candidates); + } + + hotKeyFocusDown() { + const selected = this._findNextObjectForSelection(this.state.selected, 'down'); + this.setState({ selected }); + } + + hotKeyFocusUp() { + const selected = this._findNextObjectForSelection(this.state.selected, 'up'); + this.setState({ selected }); + } + + _findAnyObjectForSelection(types) { + let node = null; + for (const type of types) { + const predicate = selectionObjectTypes.toPulsePredicate(type); + node = find(predicate, this.state.nodes) || find(predicate, this.state.edges); + if (node) { + break; + } + } + return node; + } + + _focusHorizontal(direction) { + if (!this.state.selected) { + this.setState({ + selected: this._findAnyObjectForSelection(direction === 'left' ? [ + 'sourceOutput', + 'source', + ] : [ + 'sinkInput', + 'sink', + ]), + }); + return; + } + + const type0 = this.state.selected.type; + const type1 = selectionObjectTypes[direction]( + selectionObjectTypes.fromPulseType(type0), + ); + const type2 = selectionObjectTypes[direction](type1); + + this.setState({ + selected: this._findAnyObjectForSelection([ + type1, + type2, + ]), + }); + } + + hotKeyFocusLeft() { + this._focusHorizontal('left'); + } + + hotKeyFocusRight() { + this._focusHorizontal('right'); + } + + hotKeyVolumeDown() { + } + + hotKeyVolumeUp() { + } + render() { - let edges = map(paoToEdge, flatten(map(values, [ - this.props.objects.sinkInputs, - this.props.objects.sourceOutputs, - this.props.derivations.monitorSources, - ]))); + const { nodes, edges } = this.state; - const connectedNodeKeys = new Set(); - edges.forEach(edge => { - connectedNodeKeys.add(edge.source); - connectedNodeKeys.add(edge.target); - }); - - const filteredNodeKeys = new Set(); - - const nodes = filter(node => { - if ((this.props.preferences.hideDisconnectedClients && node.type === 'client') || - (this.props.preferences.hideDisconnectedModules && node.type === 'module') || - (this.props.preferences.hideDisconnectedSources && node.type === 'source') || - (this.props.preferences.hideDisconnectedSinks && node.type === 'sink') - ) { - if (!connectedNodeKeys.has(node.id)) { - return false; - } - } - - const pai = dgoToPai.get(node); - if (pai) { - if (this.props.preferences.hideMonitors && - pai.properties.device && - pai.properties.device.class === 'monitor' - ) { - return false; - } - - if (this.props.preferences.hidePulseaudioApps) { - const binary = path([ 'properties', 'application', 'process', 'binary' ], pai) || ''; - const name = path([ 'properties', 'application', 'name' ], pai) || ''; - if (binary.startsWith('pavucontrol') || - binary.startsWith('kmix') || - name === 'paclient.js' - ) { - return false; - } - } - } - - filteredNodeKeys.add(node.id); - return true; - }, map(paoToNode, flatten(map(values, [ - this.props.objects.sinks, - this.props.objects.sources, - this.props.objects.clients, - this.props.objects.modules, - ])))); - - edges = filter(edge => { - if (this.props.preferences.hideMonitorSourceEdges && edge.type === 'monitorSource') { - return false; - } - return filteredNodeKeys.has(edge.source) && filteredNodeKeys.has(edge.target); - }, edges); - - nodes.forEach(node => { - const pai = getPaiByTypeAndIndex(node.type, node.index)({ pulse: this.props }); - dgoToPai.set(node, pai); - }); - - edges.forEach(edge => { - const pai = getPaiByTypeAndIndex(edge.type, edge.index)({ pulse: this.props }); - dgoToPai.set(edge, pai); - }); - - return r.div({ + return r(HotKeys, { + handlers: map(f => bind(f, this), pick(keys(keyMap), this)), + }, r.div({ id: 'graph', - style: {}, }, r(GraphView, { nodeKey: 'id', edgeKey: 'id', @@ -704,7 +858,9 @@ class Graph extends React.Component { selected: this.state.selected, - ...graphConfig, + nodeTypes: {}, + nodeSubtypes: {}, + edgeTypes: {}, onSelectNode: this.onSelectNode, onCreateNode: this.onCreateNode, @@ -733,7 +889,7 @@ class Graph extends React.Component { renderEdge, renderEdgeText: renderEdgeText(this.props), - })); + }))); } } @@ -751,4 +907,6 @@ module.exports = connect( preferences: state.preferences, }), dispatch => bindActionCreators(merge(pulseActions, iconsActions), dispatch), + null, + { withRef: true }, )(Graph); diff --git a/components/graph/satellites-graph.js b/components/graph/satellites-graph.js index d91a8f2..30560b8 100644 --- a/components/graph/satellites-graph.js +++ b/components/graph/satellites-graph.js @@ -70,7 +70,7 @@ class GraphView extends React.Component { selected: null, }; - this.graph = React.createRef(); + this.graphViewRef = this.props.graphViewRef || React.createRef(); Object.assign(this, { onSwapEdge: this.onSwapEdge.bind(this), @@ -144,7 +144,7 @@ class GraphView extends React.Component { const createdEdgeId = `edge-${sourceNode[nodeKey]}-${targetNode[nodeKey]}-container`; const createdEdge = document.getElementById(createdEdgeId); createdEdge.remove(); - this.graph.current.forceUpdate(); + this.graphViewRef.current.forceUpdate(); } onNodeMove(position, nodeId, shiftKey) { @@ -153,7 +153,7 @@ class GraphView extends React.Component { if (satelliteNodes) { this.constructor.repositionSatellites(position, satelliteNodes); satelliteNodes.forEach(satelliteNode => { - this.graph.current.handleNodeMove(satelliteNode, satelliteNode[nodeKey], shiftKey); + this.graphViewRef.current.handleNodeMove(satelliteNode, satelliteNode[nodeKey], shiftKey); }); } } @@ -220,7 +220,7 @@ class GraphView extends React.Component { selected, - ref: this.graph, + ref: this.graphViewRef, nodes, edges, diff --git a/components/hot-keys/index.js b/components/hot-keys/index.js new file mode 100644 index 0000000..6ec912f --- /dev/null +++ b/components/hot-keys/index.js @@ -0,0 +1,75 @@ + +const { + keys, + pick, + map, + bind, +} = require('ramda'); + +const React = require('react'); + +const r = require('r-dom'); + +const { HotKeys } = require('react-hotkeys'); + +const keyMap = { + hotKeyEscape: 'escape', + + hotKeyFocusCards: 'c', + hotKeyFocusGraph: 'g', + hotKeyFocusPreferences: 'p', + + hotKeyFocusDown: [ 'j', 'down' ], + hotKeyFocusUp: [ 'k', 'up' ], + hotKeyFocusLeft: [ 'h', 'left' ], + hotKeyFocusRight: [ 'l', 'right' ], + + hotKeyMute: 'm', +}; + +class MyHotKeys extends React.Component { + constructor(props) { + super(props); + + this.graphRef = React.createRef(); + this.cardsRef = React.createRef(); + this.preferencesRef = React.createRef(); + } + + hotKeyFocusCards() { + this.cardsRef.current.getWrappedInstance().toggle(); + this.preferencesRef.current.getWrappedInstance().close(); + } + + hotKeyFocusGraph() { + this.cardsRef.current.getWrappedInstance().close(); + this.preferencesRef.current.getWrappedInstance().close(); + this.graphRef.current.getWrappedInstance().focus(); + } + + hotKeyFocusPreferences() { + this.preferencesRef.current.getWrappedInstance().toggle(); + this.cardsRef.current.getWrappedInstance().close(); + } + + hotKeyEscape() { + this.hotKeyFocusGraph(); + this.graphRef.current.getWrappedInstance().deselect(); + } + + render() { + return r(HotKeys, { + keyMap, + handlers: map(f => bind(f, this), pick(keys(keyMap), this)), + }, this.props.children({ + graphRef: this.graphRef, + cardsRef: this.cardsRef, + preferencesRef: this.preferencesRef, + })); + } +} + +module.exports = { + HotKeys: MyHotKeys, + keyMap, +}; diff --git a/components/preferences/index.js b/components/preferences/index.js index d04f91d..faa827c 100644 --- a/components/preferences/index.js +++ b/components/preferences/index.js @@ -4,148 +4,166 @@ const { defaultTo, } = require('ramda'); +const React = require('react'); + const r = require('r-dom'); const { connect } = require('react-redux'); const { bindActionCreators } = require('redux'); -const { withStateHandlers } = require('recompose'); - const { preferences: preferencesActions } = require('../../actions'); const Button = require('../button'); const Checkbox = require('../checkbox'); const NumberInput = require('../number-input'); -const Preferences = withStateHandlers( - { - open: false, - }, - { - toggle: ({ open }) => () => ({ open: !open }), - }, -)(({ open, toggle, ...props }) => r.div({ - classSet: { - panel: true, - preferences: true, - open, - }, -}, open ? [ - r.div([ - r(Button, { - style: { width: '100%' }, - autoFocus: true, - onClick: toggle, - }, 'Close'), - ]), +class Preferences extends React.Component { + constructor(props) { + super(props); - r.hr(), + this.state = { + open: false, + }; + } - r.div([ - r(Checkbox, { - checked: props.preferences.hideDisconnectedClients, - onChange: () => props.actions.toggle('hideDisconnectedClients'), - }, 'Hide disconnected clients'), - ]), + toggle() { + this.setState({ open: !this.state.open }); + } - r.div([ - r(Checkbox, { - checked: props.preferences.hideDisconnectedModules, - onChange: () => props.actions.toggle('hideDisconnectedModules'), - }, 'Hide disconnected modules'), - ]), + close() { + this.setState({ open: false }); + } - r.div([ - r(Checkbox, { - checked: props.preferences.hideDisconnectedSource, - onChange: () => props.actions.toggle('hideDisconnectedSource'), - }, 'Hide disconnected source'), - ]), + render() { + const { open } = this.state; + const toggle = this.toggle.bind(this); - r.div([ - r(Checkbox, { - checked: props.preferences.hideDisconnectedSinks, - onChange: () => props.actions.toggle('hideDisconnectedSinks'), - }, 'Hide disconnected sinks'), - ]), - - r.hr(), - - r.div([ - r(Checkbox, { - checked: props.preferences.hideMonitorSourceEdges, - onChange: () => props.actions.toggle('hideMonitorSourceEdges'), - }, 'Hide monitor source edges'), - ]), - - r.div([ - r(Checkbox, { - checked: props.preferences.hideMonitors, - onChange: () => props.actions.toggle('hideMonitors'), - }, 'Hide monitors'), - ]), - - r.div([ - r(Checkbox, { - checked: props.preferences.hidePulseaudioApps, - onChange: () => props.actions.toggle('hidePulseaudioApps'), - }, 'Hide pulseaudio applications'), - ]), - - r.hr(), - - r.div([ - r(Checkbox, { - checked: props.preferences.hideVolumeThumbnails, - onChange: () => props.actions.toggle('hideVolumeThumbnails'), - }, 'Hide volume thumbnails'), - ]), - - r.div([ - r(Checkbox, { - checked: props.preferences.lockChannelsTogether, - onChange: () => props.actions.toggle('lockChannelsTogether'), - }, 'Lock channels together'), - ]), - - r.div([ - r(NumberInput, { - type: 'number', - value: defaultTo(150, Math.round(props.preferences.maxVolume * 100)), - onChange: e => { - const v = defaultTo(150, Math.max(0, parseInt(e.target.value, 10))); - props.actions.set({ maxVolume: v / 100 }); + return r.div({ + classSet: { + panel: true, + preferences: true, + open, }, - }, 'Maximum volume: '), - ]), + }, open ? [ + r.div([ + r(Button, { + style: { width: '100%' }, + autoFocus: true, + onClick: toggle, + }, 'Close'), + ]), - r.hr(), + r.hr(), - r.div([ - r(Checkbox, { - checked: props.preferences.showDebugInfo, - onChange: () => props.actions.toggle('showDebugInfo'), - }, 'Show debug info'), - ]), + r.div([ + r(Checkbox, { + checked: this.props.preferences.hideDisconnectedClients, + onChange: () => this.props.actions.toggle('hideDisconnectedClients'), + }, 'Hide disconnected clients'), + ]), - r.hr(), + r.div([ + r(Checkbox, { + checked: this.props.preferences.hideDisconnectedModules, + onChange: () => this.props.actions.toggle('hideDisconnectedModules'), + }, 'Hide disconnected modules'), + ]), - r.div([ - r(Button, { - style: { width: '100%' }, - onClick: props.actions.resetDefaults, - }, 'Reset to defaults'), - ]), -] : [ - r(Button, { - autoFocus: true, - onClick: toggle, - }, 'Preferences'), -])); + r.div([ + r(Checkbox, { + checked: this.props.preferences.hideDisconnectedSource, + onChange: () => this.props.actions.toggle('hideDisconnectedSource'), + }, 'Hide disconnected source'), + ]), + + r.div([ + r(Checkbox, { + checked: this.props.preferences.hideDisconnectedSinks, + onChange: () => this.props.actions.toggle('hideDisconnectedSinks'), + }, 'Hide disconnected sinks'), + ]), + + r.hr(), + + r.div([ + r(Checkbox, { + checked: this.props.preferences.hideMonitorSourceEdges, + onChange: () => this.props.actions.toggle('hideMonitorSourceEdges'), + }, 'Hide monitor source edges'), + ]), + + r.div([ + r(Checkbox, { + checked: this.props.preferences.hideMonitors, + onChange: () => this.props.actions.toggle('hideMonitors'), + }, 'Hide monitors'), + ]), + + r.div([ + r(Checkbox, { + checked: this.props.preferences.hidePulseaudioApps, + onChange: () => this.props.actions.toggle('hidePulseaudioApps'), + }, 'Hide pulseaudio applications'), + ]), + + r.hr(), + + r.div([ + r(Checkbox, { + checked: this.props.preferences.hideVolumeThumbnails, + onChange: () => this.props.actions.toggle('hideVolumeThumbnails'), + }, 'Hide volume thumbnails'), + ]), + + r.div([ + r(Checkbox, { + checked: this.props.preferences.lockChannelsTogether, + onChange: () => this.props.actions.toggle('lockChannelsTogether'), + }, 'Lock channels together'), + ]), + + r.div([ + r(NumberInput, { + type: 'number', + value: defaultTo(150, Math.round(this.props.preferences.maxVolume * 100)), + onChange: e => { + const v = defaultTo(150, Math.max(0, parseInt(e.target.value, 10))); + this.props.actions.set({ maxVolume: v / 100 }); + }, + }, 'Maximum volume: '), + ]), + + r.hr(), + + r.div([ + r(Checkbox, { + checked: this.props.preferences.showDebugInfo, + onChange: () => this.props.actions.toggle('showDebugInfo'), + }, 'Show debug info'), + ]), + + r.hr(), + + r.div([ + r(Button, { + style: { width: '100%' }, + onClick: this.props.actions.resetDefaults, + }, 'Reset to defaults'), + ]), + ] : [ + r(Button, { + autoFocus: true, + onClick: toggle, + }, 'Preferences'), + ]); + } +} module.exports = connect( state => pick([ 'preferences' ], state), dispatch => ({ actions: bindActionCreators(preferencesActions, dispatch), }), + null, + { withRef: true }, )(Preferences); diff --git a/index.css b/index.css index dad6e0a..f6858e3 100644 --- a/index.css +++ b/index.css @@ -9,6 +9,10 @@ div { box-sizing: border-box; } +div[tabindex="-1"]:focus { + outline: 0; +} + .button { background: var(--themeBgColor); color: var(--themeTextColor); diff --git a/package.json b/package.json index 6cdf55e..e69ddd5 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "react": "^16.6.0", "react-digraph": "^5.1.3", "react-dom": "^16.6.0", + "react-hotkeys": "^1.1.4", "react-redux": "^5.1.0", "recompose": "^0.30.0", "redux": "^4.0.1", diff --git a/renderer.js b/renderer.js index 4682713..57cbd78 100644 --- a/renderer.js +++ b/renderer.js @@ -1,7 +1,5 @@ /* global document */ -const React = require('react'); - const r = require('r-dom'); const { render } = require('react-dom'); @@ -13,15 +11,16 @@ const createStore = require('./store'); const Graph = require('./components/graph'); const Cards = require('./components/cards'); const Preferences = require('./components/preferences'); +const { HotKeys } = require('./components/hot-keys'); const theme = require('./utils/theme'); const Root = () => r(Provider, { store: createStore(), -}, r(React.Fragment, [ - r(Graph), - r(Cards), - r(Preferences), +}, r(HotKeys, {}, ({ graphRef, cardsRef, preferencesRef }) => [ + r(Graph, { ref: graphRef }), + r(Cards, { ref: cardsRef }), + r(Preferences, { ref: preferencesRef }), ])); Object.entries(theme.colors).forEach(([ key, value ]) => { diff --git a/yarn.lock b/yarn.lock index 1ecdc01..c1635ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3921,11 +3921,21 @@ lodash.get@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY= + lodash.isequal@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= +lodash.isobject@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/lodash.isobject/-/lodash.isobject-3.0.2.tgz#3c8fb8d5b5bf4bf90ae06e14f2a530a4ed935e1d" + integrity sha1-PI+41bW/S/kK4G4U8qUwpO2TXh0= + lodash.kebabcase@^4.0.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" @@ -4251,6 +4261,11 @@ moment@2.x.x: resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66" integrity sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y= +mousetrap@^1.5.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.2.tgz#caadd9cf886db0986fb2fee59a82f6bd37527587" + integrity sha512-jDjhi7wlHwdO6q6DS7YRmSHcuI+RVxadBkLt3KHrhd3C2b+w5pKefg3oj5beTcHZyVFA9Aksf+yEE1y5jxUjVA== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -4900,7 +4915,7 @@ promise@^7.1.1: dependencies: asap "~2.0.3" -prop-types@^15.6.1, prop-types@^15.6.2: +prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2: version "15.6.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102" integrity sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ== @@ -5032,6 +5047,17 @@ react-dom@^16.6.0: prop-types "^15.6.2" scheduler "^0.10.0" +react-hotkeys@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/react-hotkeys/-/react-hotkeys-1.1.4.tgz#a0712aa2e0c03a759fd7885808598497a4dace72" + integrity sha1-oHEqouDAOnWf14hYCFmEl6TaznI= + dependencies: + lodash.isboolean "^3.0.3" + lodash.isequal "^4.5.0" + lodash.isobject "^3.0.2" + mousetrap "^1.5.2" + prop-types "^15.6.0" + react-icon-base@2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/react-icon-base/-/react-icon-base-2.1.0.tgz#a196e33fdf1e7aaa1fda3aefbb68bdad9e82a79d"