mirror of
https://github.com/urbit/shrub.git
synced 2024-12-23 19:05:48 +03:00
Merge pull request #4856 from urbit/lf/keybinds
interface: add basic keybinds
This commit is contained in:
commit
f7aab5893c
71
pkg/interface/src/logic/lib/shortcutContext.tsx
Normal file
71
pkg/interface/src/logic/lib/shortcutContext.tsx
Normal 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]);
|
||||
}
|
@ -8,7 +8,7 @@ import _ from 'lodash';
|
||||
import f from 'lodash/fp';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { foregroundFromBackground } from '~/logic/lib/sigil';
|
||||
import { IconRef } from '~/types';
|
||||
import { IconRef, Workspace } from '~/types';
|
||||
import useContactState from '../state/contact';
|
||||
import useSettingsState from '../state/settings';
|
||||
|
||||
@ -49,6 +49,26 @@ export function parentPath(path: string) {
|
||||
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_SECOND = bigInt('18446744073709551616'); // `@ud` ~s1
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Euclidean modulo
|
||||
*/
|
||||
export function modulo(x: number, mod: number) {
|
||||
return x < 0 ? (x % mod + mod) % mod : x % mod;
|
||||
}
|
||||
|
||||
// color is a #000000 color
|
||||
export function adjustHex(color: string, amount: number): string {
|
||||
return f.flow(
|
||||
@ -259,6 +286,8 @@ export function lengthOrder(a: string, b: string) {
|
||||
return b.length - a.length;
|
||||
}
|
||||
|
||||
export const keys = <T extends {}>(o: T) => Object.keys(o) as (keyof T)[];
|
||||
|
||||
// TODO: deprecated
|
||||
export function alphabetiseAssociations(associations: any) {
|
||||
const result = {};
|
||||
|
@ -1,6 +1,17 @@
|
||||
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 { 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> {
|
||||
display: {
|
||||
@ -16,6 +27,7 @@ export interface SettingsState extends BaseState<SettingsState> {
|
||||
hideGroups: boolean;
|
||||
hideUtilities: boolean;
|
||||
};
|
||||
keyboard: ShortcutMapping;
|
||||
remoteContentPolicy: RemoteContentPolicy;
|
||||
leap: {
|
||||
categories: LeapCategories[];
|
||||
@ -59,7 +71,19 @@ const useSettingsState = createState<SettingsState>('Settings', {
|
||||
tutorial: {
|
||||
seen: true,
|
||||
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;
|
||||
|
@ -16,6 +16,8 @@ import useContactState from '~/logic/state/contact';
|
||||
import useGroupState from '~/logic/state/group';
|
||||
import useLocalState from '~/logic/state/local';
|
||||
import useSettingsState from '~/logic/state/settings';
|
||||
import { ShortcutContextProvider } from '~/logic/lib/shortcutContext';
|
||||
|
||||
import GlobalStore from '~/logic/store/store';
|
||||
import GlobalSubscription from '~/logic/subscription/global';
|
||||
import ErrorBoundary from '~/views/components/ErrorBoundary';
|
||||
@ -141,6 +143,7 @@ class App extends React.Component {
|
||||
const ourContact = this.props.contacts[`~${this.ship}`] || null;
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<ShortcutContextProvider>
|
||||
<Helmet>
|
||||
{window.ship.length < 14
|
||||
? <link rel="icon" type="image/svg+xml" href={svgDataURL(favicon())} />
|
||||
@ -178,6 +181,7 @@ class App extends React.Component {
|
||||
</Router>
|
||||
</Root>
|
||||
<div id="portal-root" />
|
||||
</ShortcutContextProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
@ -128,10 +128,27 @@ export default class ChatEditor extends Component<ChatEditorProps, ChatEditorSta
|
||||
};
|
||||
|
||||
this.editor = null;
|
||||
this.onKeyPress = this.onKeyPress.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
document.addEventListener('keydown', this.onKeyPress);
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
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 {
|
||||
@ -140,9 +157,9 @@ export default class ChatEditor extends Component<ChatEditorProps, ChatEditorSta
|
||||
if (prevProps.message !== props.message && this.editor) {
|
||||
this.editor.setValue(props.message);
|
||||
this.editor.setOption('mode', MARKDOWN_CONFIG);
|
||||
this.editor?.focus();
|
||||
this.editor.execCommand('goDocEnd');
|
||||
this.editor?.focus();
|
||||
//this.editor?.focus();
|
||||
//this.editor.execCommand('goDocEnd');
|
||||
//this.editor?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -281,7 +298,6 @@ return;
|
||||
onChange={(e, d, v) => this.messageChange(e, d, v)}
|
||||
editorDidMount={(editor) => {
|
||||
this.editor = editor;
|
||||
editor.focus();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -12,6 +12,7 @@ import { LeapSettings } from './components/lib/LeapSettings';
|
||||
import { NotificationPreferences } from './components/lib/NotificationPref';
|
||||
import S3Form from './components/lib/S3Form';
|
||||
import SecuritySettings from './components/lib/Security';
|
||||
import ShortcutSettings from './components/lib/ShortcutSettings';
|
||||
|
||||
export const Skeleton = (props: { children: ReactNode }) => (
|
||||
<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='LeapArrow' text='Leap' hash='leap' />
|
||||
<SidebarItem icon='Node' text='CalmEngine' hash='calm' />
|
||||
<SidebarItem icon='Keyboard' text='Shortcuts' hash='shortcuts' />
|
||||
<SidebarItem
|
||||
icon='Locked'
|
||||
text='Devices + Security'
|
||||
@ -129,6 +131,7 @@ return;
|
||||
/>
|
||||
)}
|
||||
{hash === 'display' && <DisplayForm api={props.api} />}
|
||||
{hash === 'shortcuts' && <ShortcutSettings api={props.api} />}
|
||||
{hash === 's3' && <S3Form api={props.api} />}
|
||||
{hash === 'leap' && <LeapSettings api={props.api} />}
|
||||
{hash === 'calm' && <CalmPrefs api={props.api} />}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Box } from '@tlon/indigo-react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { Route, Switch, useHistory } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
|
||||
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 TermApp from '~/views/apps/term/app';
|
||||
import ErrorComponent from '~/views/components/Error';
|
||||
import { useShortcut } from '~/logic/state/settings';
|
||||
|
||||
import Landscape from '~/views/landscape/index';
|
||||
import GraphApp from '../../apps/graph/App';
|
||||
|
||||
@ -21,6 +23,21 @@ export const Container = styled(Box)`
|
||||
`;
|
||||
|
||||
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(
|
||||
'registeredProtocol', false
|
||||
);
|
||||
|
@ -1,10 +1,14 @@
|
||||
import { AppAssociations } from '@urbit/api';
|
||||
import React, { ReactElement } from 'react';
|
||||
import { alphabeticalOrder } from '~/logic/lib/util';
|
||||
import useMetadataState from '~/logic/state/metadata';
|
||||
import { Workspace } from '~/types/workspace';
|
||||
import { SidebarItem } from './SidebarItem';
|
||||
import React, { ReactElement, useCallback, useEffect } from 'react';
|
||||
import { AppAssociations, Groups, Rolodex } from '@urbit/api';
|
||||
|
||||
import { alphabeticalOrder, getResourcePath, modulo } from '~/logic/lib/util';
|
||||
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(
|
||||
associations: AppAssociations,
|
||||
@ -47,6 +51,7 @@ export function SidebarList(props: {
|
||||
}): ReactElement {
|
||||
const { selected, group, config, workspace } = props;
|
||||
const associationState = useMetadataState(state => state.associations);
|
||||
const graphKeys = useGraphState(s => s.graphKeys);
|
||||
const associations = { ...associationState.graph };
|
||||
|
||||
const ordered = Object.keys(associations)
|
||||
@ -72,6 +77,30 @@ export function SidebarList(props: {
|
||||
})
|
||||
.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 (
|
||||
<>
|
||||
{ordered.map((path) => {
|
||||
|
@ -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 React, { ReactElement, ReactNode, useMemo } from 'react';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import useGraphState from '~/logic/state/graph';
|
||||
import useHarkState from '~/logic/state/hark';
|
||||
import { Workspace } from '~/types/workspace';
|
||||
import { Body } from '~/views/components/Body';
|
||||
import ErrorBoundary from '~/views/components/ErrorBoundary';
|
||||
import { useShortcut } from '~/logic/state/settings';
|
||||
import { useGraphModule } from './Sidebar/Apps';
|
||||
import { Sidebar } from './Sidebar/Sidebar';
|
||||
|
||||
interface SkeletonProps {
|
||||
children: ReactNode;
|
||||
@ -21,6 +22,10 @@ interface SkeletonProps {
|
||||
}
|
||||
|
||||
export function Skeleton(props: SkeletonProps): ReactElement {
|
||||
const [sidebar, setSidebar] = useState(true)
|
||||
useShortcut('hideSidebar', useCallback(() => {
|
||||
setSidebar(s => !s);
|
||||
}, []));
|
||||
const graphs = useGraphState(state => state.graphs);
|
||||
const graphKeys = useGraphState(state => state.graphKeys);
|
||||
const unreads = useHarkState(state => state.unreads);
|
||||
@ -32,7 +37,7 @@ export function Skeleton(props: SkeletonProps): ReactElement {
|
||||
[graphConfig]
|
||||
);
|
||||
|
||||
return (
|
||||
return !sidebar ? (<Body> {props.children} </Body>) : (
|
||||
<Body
|
||||
display="grid"
|
||||
gridTemplateColumns={
|
||||
|
@ -1,6 +1,6 @@
|
||||
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');
|
||||
whyDidYouRender(React, {
|
||||
trackAllPureComponents: true
|
||||
|
@ -39,7 +39,7 @@ export default class BigIntOrderedMap<V> implements Iterable<[BigInteger, V]> {
|
||||
items.forEach(([key, value]) => {
|
||||
draft.root[key.toString()] = castDraft(value);
|
||||
});
|
||||
draft.generateCachedIter();
|
||||
draft.cachedIter = null;
|
||||
},
|
||||
(patches) => {
|
||||
//console.log(`gassed with ${JSON.stringify(patches, null, 2)}`);
|
||||
|
Loading…
Reference in New Issue
Block a user