Merge pull request #4856 from urbit/lf/keybinds

interface: add basic keybinds
This commit is contained in:
matildepark 2021-05-13 13:24:36 -04:00 committed by GitHub
commit f7aab5893c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 334 additions and 19 deletions

View File

@ -0,0 +1,71 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import _ from 'lodash';
import { getChord } from '~/logic/lib/util';
type Handler = (e: KeyboardEvent) => void;
const fallback: ShortcutContextProps = {
add: () => {},
remove: () => {},
};
export const ShortcutContext = createContext(fallback);
export interface ShortcutContextProps {
add: (cb: (e: KeyboardEvent) => void, key: string) => void;
remove: (cb: (e: KeyboardEvent) => void, key: string) => void;
}
export function ShortcutContextProvider({ children }) {
const [shortcuts, setShortcuts] = useState({} as Record<string, Handler>);
const handlerRef = useRef<Handler>(() => {});
const add = useCallback((cb: Handler, key: string) => {
setShortcuts((s) => ({ ...s, [key]: cb }));
}, []);
const remove = useCallback((cb: Handler, key: string) => {
setShortcuts((s) => (key in s ? _.omit(s, key) : s));
}, []);
useEffect(() => {
function onKeypress(e: KeyboardEvent) {
handlerRef.current(e);
}
document.addEventListener('keypress', onKeypress);
return () => {
document.removeEventListener('keypress', onKeypress);
};
}, []);
useEffect(() => {
handlerRef.current = function (e: KeyboardEvent) {
const chord = getChord(e);
console.log(chord);
shortcuts?.[chord]?.(e);
};
}, [shortcuts]);
const value = useMemo(() => ({ add, remove }), [add, remove])
return (
<ShortcutContext.Provider value={value}>
{children}
</ShortcutContext.Provider>
);
}
export function useShortcut(key: string, cb: Handler) {
const { add, remove } = useContext(ShortcutContext);
useEffect(() => {
add(cb, key);
return () => {
remove(cb, key);
};
}, [key, cb]);
}

View File

@ -8,7 +8,7 @@ import _ from 'lodash';
import f from 'lodash/fp'; import f from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { foregroundFromBackground } from '~/logic/lib/sigil'; import { foregroundFromBackground } from '~/logic/lib/sigil';
import { IconRef } from '~/types'; import { IconRef, Workspace } from '~/types';
import useContactState from '../state/contact'; import useContactState from '../state/contact';
import useSettingsState from '../state/settings'; import useSettingsState from '../state/settings';
@ -49,6 +49,26 @@ export function parentPath(path: string) {
return _.dropRight(path.split('/'), 1).join('/'); return _.dropRight(path.split('/'), 1).join('/');
} }
export const getChord = (e: KeyboardEvent) => {
let chord = [e.key];
if(e.metaKey) {
chord.unshift('meta');
}
if(e.ctrlKey) {
chord.unshift('ctrl');
}
return chord.join('+');
}
export function getResourcePath(workspace: Workspace, path: string, joined: boolean, mod: string) {
const base = workspace.type === 'group'
? `/~landscape${workspace.group}`
: workspace.type === 'home'
? `/~landscape/home`
: `/~landscape/messages`;
return `${base}/${joined ? 'resource' : 'join'}/${mod}${path}`
}
const DA_UNIX_EPOCH = bigInt('170141184475152167957503069145530368000'); // `@ud` ~1970.1.1 const DA_UNIX_EPOCH = bigInt('170141184475152167957503069145530368000'); // `@ud` ~1970.1.1
const DA_SECOND = bigInt('18446744073709551616'); // `@ud` ~s1 const DA_SECOND = bigInt('18446744073709551616'); // `@ud` ~s1
export function daToUnix(da: BigInteger) { export function daToUnix(da: BigInteger) {
@ -105,6 +125,13 @@ export function clamp(x: number, min: number, max: number) {
return Math.max(min, Math.min(max, x)); return Math.max(min, Math.min(max, x));
} }
/**
* Euclidean modulo
*/
export function modulo(x: number, mod: number) {
return x < 0 ? (x % mod + mod) % mod : x % mod;
}
// color is a #000000 color // color is a #000000 color
export function adjustHex(color: string, amount: number): string { export function adjustHex(color: string, amount: number): string {
return f.flow( return f.flow(
@ -259,6 +286,8 @@ export function lengthOrder(a: string, b: string) {
return b.length - a.length; return b.length - a.length;
} }
export const keys = <T extends {}>(o: T) => Object.keys(o) as (keyof T)[];
// TODO: deprecated // TODO: deprecated
export function alphabetiseAssociations(associations: any) { export function alphabetiseAssociations(associations: any) {
const result = {}; const result = {};

View File

@ -1,6 +1,17 @@
import f from 'lodash/fp'; import f from 'lodash/fp';
import { RemoteContentPolicy, LeapCategories, leapCategories } from "~/types/local-update";
import { useShortcut as usePlainShortcut } from '~/logic/lib/shortcutContext';
import { BaseState, createState } from '~/logic/state/base'; import { BaseState, createState } from '~/logic/state/base';
import { LeapCategories, leapCategories, RemoteContentPolicy } from '~/types/local-update'; import {useCallback} from 'react';
export interface ShortcutMapping {
cycleForward: string;
cycleBack: string;
navForward: string;
navBack: string;
hideSidebar: string;
}
export interface SettingsState extends BaseState<SettingsState> { export interface SettingsState extends BaseState<SettingsState> {
display: { display: {
@ -16,6 +27,7 @@ export interface SettingsState extends BaseState<SettingsState> {
hideGroups: boolean; hideGroups: boolean;
hideUtilities: boolean; hideUtilities: boolean;
}; };
keyboard: ShortcutMapping;
remoteContentPolicy: RemoteContentPolicy; remoteContentPolicy: RemoteContentPolicy;
leap: { leap: {
categories: LeapCategories[]; categories: LeapCategories[];
@ -59,7 +71,19 @@ const useSettingsState = createState<SettingsState>('Settings', {
tutorial: { tutorial: {
seen: true, seen: true,
joined: undefined joined: undefined
},
keyboard: {
cycleForward: 'ctrl+\'',
cycleBack: 'ctrl+;',
navForward: 'ctrl+[',
navBack: 'ctrl+[',
hideSidebar: 'ctrl+\\'
} }
}); });
export function useShortcut<T extends keyof ShortcutMapping>(name: T, cb: (e: KeyboardEvent) => void) {
const key = useSettingsState(useCallback(s => s.keyboard[name], [name]));
return usePlainShortcut(key, cb);
}
export default useSettingsState; export default useSettingsState;

View File

@ -16,6 +16,8 @@ import useContactState from '~/logic/state/contact';
import useGroupState from '~/logic/state/group'; import useGroupState from '~/logic/state/group';
import useLocalState from '~/logic/state/local'; import useLocalState from '~/logic/state/local';
import useSettingsState from '~/logic/state/settings'; import useSettingsState from '~/logic/state/settings';
import { ShortcutContextProvider } from '~/logic/lib/shortcutContext';
import GlobalStore from '~/logic/store/store'; import GlobalStore from '~/logic/store/store';
import GlobalSubscription from '~/logic/subscription/global'; import GlobalSubscription from '~/logic/subscription/global';
import ErrorBoundary from '~/views/components/ErrorBoundary'; import ErrorBoundary from '~/views/components/ErrorBoundary';
@ -141,6 +143,7 @@ class App extends React.Component {
const ourContact = this.props.contacts[`~${this.ship}`] || null; const ourContact = this.props.contacts[`~${this.ship}`] || null;
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<ShortcutContextProvider>
<Helmet> <Helmet>
{window.ship.length < 14 {window.ship.length < 14
? <link rel="icon" type="image/svg+xml" href={svgDataURL(favicon())} /> ? <link rel="icon" type="image/svg+xml" href={svgDataURL(favicon())} />
@ -178,6 +181,7 @@ class App extends React.Component {
</Router> </Router>
</Root> </Root>
<div id="portal-root" /> <div id="portal-root" />
</ShortcutContextProvider>
</ThemeProvider> </ThemeProvider>
); );
} }

View File

@ -128,10 +128,27 @@ export default class ChatEditor extends Component<ChatEditorProps, ChatEditorSta
}; };
this.editor = null; this.editor = null;
this.onKeyPress = this.onKeyPress.bind(this);
}
componentDidMount() {
document.addEventListener('keydown', this.onKeyPress);
} }
componentWillUnmount(): void { componentWillUnmount(): void {
this.props.onUnmount(this.state.message); this.props.onUnmount(this.state.message);
document.removeEventListener('keydown', this.onKeyPress);
}
onKeyPress(e) {
const focusedTag = document.activeElement?.nodeName?.toLowerCase();
const shouldCapture = !(focusedTag === 'textarea' || focusedTag === 'input' || e.metaKey || e.ctrlKey);
if(/^[a-z]|[A-Z]$/.test(e.key) && shouldCapture) {
this.editor.focus();
}
if(e.key === 'Escape') {
this.editor.getInputField().blur();
}
} }
componentDidUpdate(prevProps: ChatEditorProps): void { componentDidUpdate(prevProps: ChatEditorProps): void {
@ -140,9 +157,9 @@ export default class ChatEditor extends Component<ChatEditorProps, ChatEditorSta
if (prevProps.message !== props.message && this.editor) { if (prevProps.message !== props.message && this.editor) {
this.editor.setValue(props.message); this.editor.setValue(props.message);
this.editor.setOption('mode', MARKDOWN_CONFIG); this.editor.setOption('mode', MARKDOWN_CONFIG);
this.editor?.focus(); //this.editor?.focus();
this.editor.execCommand('goDocEnd'); //this.editor.execCommand('goDocEnd');
this.editor?.focus(); //this.editor?.focus();
return; return;
} }
@ -281,7 +298,6 @@ return;
onChange={(e, d, v) => this.messageChange(e, d, v)} onChange={(e, d, v) => this.messageChange(e, d, v)}
editorDidMount={(editor) => { editorDidMount={(editor) => {
this.editor = editor; this.editor = editor;
editor.focus();
}} }}
{...props} {...props}
/> />

View File

@ -0,0 +1,117 @@
import React, { useCallback, useEffect, useState } from 'react';
import _ from 'lodash';
import { Box, Col, Text } from '@tlon/indigo-react';
import { Formik, Form, useField } from 'formik';
import GlobalApi from '~/logic/api/global';
import { getChord } from '~/logic/lib/util';
import useSettingsState, {
selectSettingsState,
ShortcutMapping,
} from '~/logic/state/settings';
import { AsyncButton } from '~/views/components/AsyncButton';
import { BackButton } from './BackButton';
interface ShortcutSettingsProps {
api: GlobalApi;
}
const settingsSel = selectSettingsState(['keyboard']);
export function ChordInput(props: { id: string; label: string }) {
const { id, label } = props;
const [capturing, setCapturing] = useState(false);
const [{ value }, , { setValue }] = useField(id);
const onCapture = useCallback(() => {
setCapturing(true);
}, []);
useEffect(() => {
if (!capturing) {
return;
}
function onKeydown(e: KeyboardEvent) {
if (['Control', 'Shift', 'Meta'].includes(e.key)) {
return;
}
const chord = getChord(e);
setValue(chord);
e.stopImmediatePropagation();
e.preventDefault();
setCapturing(false);
}
document.addEventListener('keydown', onKeydown);
return () => {
document.removeEventListener('keydown', onKeydown);
};
}, [capturing]);
return (
<>
<Box p="1">
<Text>{label}</Text>
</Box>
<Box
border="1"
borderColor="lightGray"
borderRadius="2"
onClick={onCapture}
p="1"
>
<Text>{capturing ? 'Press' : value}</Text>
</Box>
</>
);
}
export default function ShortcutSettings(props: ShortcutSettingsProps) {
const { api } = props;
const { keyboard } = useSettingsState(settingsSel);
return (
<Formik
initialValues={keyboard}
onSubmit={async (values: ShortcutMapping, actions) => {
const promises = _.map(values, (value, key) => {
return keyboard[key] !== value
? api.settings.putEntry('keyboard', key, value)
: Promise.resolve();
});
await Promise.all(promises);
actions.setStatus({ success: null });
}}
>
<Form>
<BackButton />
<Col p="5" pt="4" gapY="5">
<Col gapY="1" mt="0">
<Text color="black" fontSize={2} fontWeight="medium">
Shortcuts
</Text>
<Text gray>Customize keyboard shortcuts for landscape</Text>
</Col>
<Box
display="grid"
gridTemplateColumns="1fr 100px"
gridGap={3}
maxWidth="500px"
>
<ChordInput id="navForward" label="Go forward in history" />
<ChordInput id="navBack" label="Go backward in history" />
<ChordInput
id="cycleForward"
label="Cycle forward through channel list"
/>
<ChordInput
id="cycleBack"
label="Cycle backward through channel list"
/>
<ChordInput id="hideSidebar" label="Show/hide group sidebar" />
</Box>
<AsyncButton primary width="fit-content">Save Changes</AsyncButton>
</Col>
</Form>
</Formik>
);
}

View File

@ -12,6 +12,7 @@ import { LeapSettings } from './components/lib/LeapSettings';
import { NotificationPreferences } from './components/lib/NotificationPref'; import { NotificationPreferences } from './components/lib/NotificationPref';
import S3Form from './components/lib/S3Form'; import S3Form from './components/lib/S3Form';
import SecuritySettings from './components/lib/Security'; import SecuritySettings from './components/lib/Security';
import ShortcutSettings from './components/lib/ShortcutSettings';
export const Skeleton = (props: { children: ReactNode }) => ( export const Skeleton = (props: { children: ReactNode }) => (
<Box height='100%' width='100%' px={[0, 3]} pb={[0, 3]} borderRadius={1}> <Box height='100%' width='100%' px={[0, 3]} pb={[0, 3]} borderRadius={1}>
@ -113,6 +114,7 @@ return;
<SidebarItem icon='Upload' text='Remote Storage' hash='s3' /> <SidebarItem icon='Upload' text='Remote Storage' hash='s3' />
<SidebarItem icon='LeapArrow' text='Leap' hash='leap' /> <SidebarItem icon='LeapArrow' text='Leap' hash='leap' />
<SidebarItem icon='Node' text='CalmEngine' hash='calm' /> <SidebarItem icon='Node' text='CalmEngine' hash='calm' />
<SidebarItem icon='Keyboard' text='Shortcuts' hash='shortcuts' />
<SidebarItem <SidebarItem
icon='Locked' icon='Locked'
text='Devices + Security' text='Devices + Security'
@ -129,6 +131,7 @@ return;
/> />
)} )}
{hash === 'display' && <DisplayForm api={props.api} />} {hash === 'display' && <DisplayForm api={props.api} />}
{hash === 'shortcuts' && <ShortcutSettings api={props.api} />}
{hash === 's3' && <S3Form api={props.api} />} {hash === 's3' && <S3Form api={props.api} />}
{hash === 'leap' && <LeapSettings api={props.api} />} {hash === 'leap' && <LeapSettings api={props.api} />}
{hash === 'calm' && <CalmPrefs api={props.api} />} {hash === 'calm' && <CalmPrefs api={props.api} />}

View File

@ -1,6 +1,6 @@
import { Box } from '@tlon/indigo-react'; import { Box } from '@tlon/indigo-react';
import React, { useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { Route, Switch } from 'react-router-dom'; import { Route, Switch, useHistory } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState'; import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
import LaunchApp from '~/views/apps/launch/App'; import LaunchApp from '~/views/apps/launch/App';
@ -10,6 +10,8 @@ import Profile from '~/views/apps/profile/profile';
import Settings from '~/views/apps/settings/settings'; import Settings from '~/views/apps/settings/settings';
import TermApp from '~/views/apps/term/app'; import TermApp from '~/views/apps/term/app';
import ErrorComponent from '~/views/components/Error'; import ErrorComponent from '~/views/components/Error';
import { useShortcut } from '~/logic/state/settings';
import Landscape from '~/views/landscape/index'; import Landscape from '~/views/landscape/index';
import GraphApp from '../../apps/graph/App'; import GraphApp from '../../apps/graph/App';
@ -21,6 +23,21 @@ export const Container = styled(Box)`
`; `;
export const Content = (props) => { export const Content = (props) => {
const history = useHistory();
useShortcut('navForward', useCallback((e) => {
e.preventDefault();
e.stopImmediatePropagation();
history.goForward();
}, [history.goForward]));
useShortcut('navBack', useCallback((e) => {
e.preventDefault();
e.stopImmediatePropagation();
history.goBack();
}, [history.goBack]));
const [hasProtocol, setHasProtocol] = useLocalStorageState( const [hasProtocol, setHasProtocol] = useLocalStorageState(
'registeredProtocol', false 'registeredProtocol', false
); );

View File

@ -1,10 +1,14 @@
import { AppAssociations } from '@urbit/api'; import React, { ReactElement, useCallback, useEffect } from 'react';
import React, { ReactElement } from 'react'; import { AppAssociations, Groups, Rolodex } from '@urbit/api';
import { alphabeticalOrder } from '~/logic/lib/util';
import useMetadataState from '~/logic/state/metadata'; import { alphabeticalOrder, getResourcePath, modulo } from '~/logic/lib/util';
import { Workspace } from '~/types/workspace';
import { SidebarItem } from './SidebarItem';
import { SidebarAppConfigs, SidebarListConfig, SidebarSort } from './types'; import { SidebarAppConfigs, SidebarListConfig, SidebarSort } from './types';
import { SidebarItem } from './SidebarItem';
import { Workspace } from '~/types/workspace';
import useMetadataState from '~/logic/state/metadata';
import useGraphState from '~/logic/state/graph';
import {useHistory} from 'react-router';
import { useShortcut } from '~/logic/state/settings';
function sidebarSort( function sidebarSort(
associations: AppAssociations, associations: AppAssociations,
@ -47,6 +51,7 @@ export function SidebarList(props: {
}): ReactElement { }): ReactElement {
const { selected, group, config, workspace } = props; const { selected, group, config, workspace } = props;
const associationState = useMetadataState(state => state.associations); const associationState = useMetadataState(state => state.associations);
const graphKeys = useGraphState(s => s.graphKeys);
const associations = { ...associationState.graph }; const associations = { ...associationState.graph };
const ordered = Object.keys(associations) const ordered = Object.keys(associations)
@ -72,6 +77,30 @@ export function SidebarList(props: {
}) })
.sort(sidebarSort(associations, props.apps)[config.sortBy]); .sort(sidebarSort(associations, props.apps)[config.sortBy]);
const history = useHistory();
const cycleChannels = useCallback((backward: boolean) => {
const idx = ordered.findIndex(s => s === selected);
const offset = backward ? -1 : 1
const newIdx = modulo(idx+offset, ordered.length - 1);
const { metadata, resource } = associations[ordered[newIdx]];
const joined = graphKeys.has(resource.slice(7));
const path = getResourcePath(workspace, resource, joined, metadata.config.graph)
history.push(path)
}, [selected, history.push]);
useShortcut('cycleForward', useCallback((e: KeyboardEvent) => {
cycleChannels(false);
e.preventDefault();
}, [cycleChannels]));
useShortcut('cycleBack', useCallback((e: KeyboardEvent) => {
cycleChannels(true);
e.preventDefault();
}, [cycleChannels]))
return ( return (
<> <>
{ordered.map((path) => { {ordered.map((path) => {

View File

@ -1,13 +1,14 @@
import React, { Children, ReactElement, ReactNode, useCallback, useMemo, useState } from 'react';
import { Sidebar } from './Sidebar/Sidebar';
import { AppName } from '@urbit/api'; import { AppName } from '@urbit/api';
import React, { ReactElement, ReactNode, useMemo } from 'react';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import useGraphState from '~/logic/state/graph'; import useGraphState from '~/logic/state/graph';
import useHarkState from '~/logic/state/hark'; import useHarkState from '~/logic/state/hark';
import { Workspace } from '~/types/workspace'; import { Workspace } from '~/types/workspace';
import { Body } from '~/views/components/Body'; import { Body } from '~/views/components/Body';
import ErrorBoundary from '~/views/components/ErrorBoundary'; import ErrorBoundary from '~/views/components/ErrorBoundary';
import { useShortcut } from '~/logic/state/settings';
import { useGraphModule } from './Sidebar/Apps'; import { useGraphModule } from './Sidebar/Apps';
import { Sidebar } from './Sidebar/Sidebar';
interface SkeletonProps { interface SkeletonProps {
children: ReactNode; children: ReactNode;
@ -21,6 +22,10 @@ interface SkeletonProps {
} }
export function Skeleton(props: SkeletonProps): ReactElement { export function Skeleton(props: SkeletonProps): ReactElement {
const [sidebar, setSidebar] = useState(true)
useShortcut('hideSidebar', useCallback(() => {
setSidebar(s => !s);
}, []));
const graphs = useGraphState(state => state.graphs); const graphs = useGraphState(state => state.graphs);
const graphKeys = useGraphState(state => state.graphKeys); const graphKeys = useGraphState(state => state.graphKeys);
const unreads = useHarkState(state => state.unreads); const unreads = useHarkState(state => state.unreads);
@ -32,7 +37,7 @@ export function Skeleton(props: SkeletonProps): ReactElement {
[graphConfig] [graphConfig]
); );
return ( return !sidebar ? (<Body> {props.children} </Body>) : (
<Body <Body
display="grid" display="grid"
gridTemplateColumns={ gridTemplateColumns={

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
if (process.env.NODE_ENV === 'development') { if (false && process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render'); const whyDidYouRender = require('@welldone-software/why-did-you-render');
whyDidYouRender(React, { whyDidYouRender(React, {
trackAllPureComponents: true trackAllPureComponents: true

View File

@ -39,7 +39,7 @@ export default class BigIntOrderedMap<V> implements Iterable<[BigInteger, V]> {
items.forEach(([key, value]) => { items.forEach(([key, value]) => {
draft.root[key.toString()] = castDraft(value); draft.root[key.toString()] = castDraft(value);
}); });
draft.generateCachedIter(); draft.cachedIter = null;
}, },
(patches) => { (patches) => {
//console.log(`gassed with ${JSON.stringify(patches, null, 2)}`); //console.log(`gassed with ${JSON.stringify(patches, null, 2)}`);