feat: support elementary keyboard shortcuts

This commit is contained in:
Shusui MOYATANI
2023-03-08 01:55:30 +09:00
parent 410b18d3a2
commit e581d3fc74
9 changed files with 218 additions and 103 deletions

View File

@@ -0,0 +1,54 @@
import { onMount } from 'solid-js';
import { useRequestMessage, useHandleMessage } from '@/hooks/useMessageBus';
type UseHandleCommandProps = {
commandType: string;
handler: (command: Command) => void;
};
type CommandBase<T> = { command: T };
export type OpenPostForm = CommandBase<'openPostForm'>;
export type ClosePostForm = CommandBase<'closePostForm'>;
export type MoveToNextItem = CommandBase<'moveToNextItem'>;
export type MoveToPrevItem = CommandBase<'moveToPrevItem'>;
export type MoveToPrevColumn = CommandBase<'moveToPrevColumn'>;
export type MoveToNextColumn = CommandBase<'moveToNextColumn'>;
export type MoveToColumn = CommandBase<'moveToNextColumn'> & { columnIndex: number };
export type Like = CommandBase<'like'>;
export type Repost = CommandBase<'repost'>;
export type OpenReplyForm = CommandBase<'openReplyForm'>;
export type OpenHelp = CommandBase<'openHelp'>;
export type OpenItemDetail = CommandBase<'openItemDetail'>;
export type CloseItemDetail = CommandBase<'closeItemDetail'>;
export type Command =
| OpenPostForm
| ClosePostForm
| MoveToNextItem
| MoveToPrevItem
| MoveToPrevColumn
| MoveToNextColumn
| Like
| Repost
| OpenReplyForm
| OpenHelp
| OpenItemDetail
| CloseItemDetail;
export type CommandType = Command['command'];
export const useRequestCommand = () =>
useRequestMessage<Command, void>(() => ({ id: 'CommandChannel' }));
export const useHandleCommand = (propsProvider: () => UseHandleCommandProps) => {
useHandleMessage<Command, void>(() => ({
id: 'CommandChannel',
handler: (command) => {
const { commandType, handler } = propsProvider();
if (command.command === commandType) {
handler(command);
}
},
}));
};

View File

@@ -1,11 +1,12 @@
import { createSignal, onMount, type Signal } from 'solid-js';
import { createSignal, createMemo, onMount, type Signal, onCleanup } from 'solid-js';
const [channels, setChannels]: Signal<Record<string, MessageChannel>> = createSignal({});
export type UseRequestMessageProps = {
id: string;
};
export const CommandChannel = 'CommandChannel' as const;
export type UseMessageChannelProps = {
id: typeof CommandChannel;
export type UseHandleMessageProps<Req, Res> = {
id: string;
handler: (req: Req) => Res | Promise<Res>;
};
export type MessageChannelRequest<T> = {
@@ -15,39 +16,28 @@ export type MessageChannelRequest<T> = {
export type MessageChannelResponse<T> = {
requestId: string;
response: T;
response?: T;
error?: any;
};
const [channels, setChannels]: Signal<Record<string, MessageChannel>> = createSignal({});
const registerChannelIfNotExist = (id: string) => {
if (channels()[id] == null) {
setChannels((currentChannels) => ({
...currentChannels,
[id]: new MessageChannel(),
}));
}
};
// https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
type Clonable =
| number
| string
| boolean
| null
| bigint
| Date
| Array<Clonable>
| Record<string, Clonable>;
const useMessageBus = <Req extends Clonable, Res extends Clonable>(
propsProvider: () => UseMessageChannelProps,
) => {
onMount(() => {
const { id } = propsProvider();
if (channel() == null) {
setChannels((currentChannels) => ({
...currentChannels,
[id]: new MessageChannel(),
}));
}
});
export const useRequestMessage = <Req, Res>(propsProvider: () => UseRequestMessageProps) => {
const channel = () => channels()[propsProvider().id];
const sendRequest = (requestId: string, message: Req) => {
const request: MessageChannelRequest<Req> = { requestId, request: message };
const messageStr = JSON.stringify(request);
channel().port1.postMessage(messageStr);
channel().port1.postMessage(request);
};
const waitResponse = (requestId: string, timeoutMs = 1000): Promise<Res> =>
@@ -58,7 +48,11 @@ const useMessageBus = <Req extends Clonable, Res extends Clonable>(
if (data.requestId !== requestId) return;
channel().port1.removeEventListener('message', listener);
resolve(data.response);
if (data.response != null) {
resolve(data.response);
} else {
reject(data.error);
}
};
setTimeout(() => {
@@ -70,22 +64,48 @@ const useMessageBus = <Req extends Clonable, Res extends Clonable>(
channel().port1.start();
});
const sendResponse = (res: Res) => {};
onMount(() => {
const { id } = propsProvider();
registerChannelIfNotExist(id);
});
return {
async requst(message: Req): Promise<Res> {
const requestId = Math.random().toString();
const response = waitResponse(requestId);
sendRequest(requestId, message);
return response;
},
handle(handler: (message: Req) => Res | Promise<Res>) {
channel().port2.addEventListener('message', (ev) => {
const request = event.data as MessageChannelRequest<Req>;
const res = handler(request.request).then((res) => {});
});
},
return async (message: Req): Promise<Res> => {
const requestId = Math.random().toString();
const response = waitResponse(requestId);
sendRequest(requestId, message);
return response;
};
};
export default useMessageBus;
export const useHandleMessage = <Req, Res>(
propsProvider: () => UseHandleMessageProps<Req, Res>,
) => {
const props = createMemo(propsProvider);
const channel = () => channels()[props().id];
onMount(() => {
const port = channel().port2;
const messageHandler = (event: MessageEvent) => {
const { requestId, request } = event.data as MessageChannelRequest<Req>;
const result = props().handler(request);
const resultPromise = result instanceof Promise ? result : Promise.resolve(result);
resultPromise
.then((res) => {
const response: MessageChannelResponse<Res> = { requestId, response: res };
port.postMessage(response);
})
.catch((err) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const response: MessageChannelResponse<Res> = { requestId, error: err };
port.postMessage(response);
});
};
port.addEventListener('message', messageHandler);
port.start();
onCleanup(() => {
port.removeEventListener('message', messageHandler);
});
});
};

View File

@@ -3,24 +3,26 @@
import { onMount, onCleanup, type JSX } from 'solid-js';
type Shortcut = { key: string; command: string };
import { useRequestCommand, type Command } from '@/hooks/useCommandBus';
type Shortcut = { key: string; command: Command };
const defaultShortcut: Shortcut[] = [
{ key: 'n', command: 'openPostForm' },
{ key: 'h', command: 'moveToPrevColumn' },
{ key: 'j', command: 'moveToNextItem' },
{ key: 'k', command: 'moveToPrevItem' },
{ key: 'l', command: 'moveToNextColumn' },
{ key: 'ArrowLeft', command: 'moveToPrevColumn' },
{ key: 'ArrowDown', command: 'moveToNextItem' },
{ key: 'ArrowUp', command: 'moveToPrevItem' },
{ key: 'ArrowRight', command: 'moveToNextColumn' },
{ key: 'f', command: 'like' },
{ key: 't', command: 'repost' },
{ key: 'r', command: 'openReplyForm' },
{ key: '?', command: 'openHelp' },
{ key: 'Enter', command: 'openItemDetail' },
{ key: 'Backspace', command: 'closeItemDetail' },
{ key: 'n', command: { command: 'openPostForm' } },
{ key: 'h', command: { command: 'moveToPrevColumn' } },
{ key: 'j', command: { command: 'moveToNextItem' } },
{ key: 'k', command: { command: 'moveToPrevItem' } },
{ key: 'l', command: { command: 'moveToNextColumn' } },
{ key: 'ArrowLeft', command: { command: 'moveToPrevColumn' } },
{ key: 'ArrowDown', command: { command: 'moveToNextItem' } },
{ key: 'ArrowUp', command: { command: 'moveToPrevItem' } },
{ key: 'ArrowRight', command: { command: 'moveToNextColumn' } },
{ key: 'f', command: { command: 'like' } },
{ key: 't', command: { command: 'repost' } },
{ key: 'r', command: { command: 'openReplyForm' } },
{ key: '?', command: { command: 'openHelp' } },
{ key: 'Enter', command: { command: 'openItemDetail' } },
{ key: 'Backspace', command: { command: 'closeItemDetail' } },
];
type UseShortcutKeysProps = {
@@ -40,7 +42,7 @@ const useShortcutKeys = ({ shortcuts = defaultShortcut, onShortcut }: UseShortcu
const shortcutsMap = createShortcutsMap(shortcuts);
onMount(() => {
const handleKeydown: JSX.EventHandler<Window, KeyboardEvent> = (ev) => {
const handleKeydown = (ev: KeyboardEvent) => {
if (ev.type !== 'keydown') return;
if (ev.target instanceof HTMLTextAreaElement || ev.target instanceof HTMLInputElement) return;
@@ -59,4 +61,14 @@ const useShortcutKeys = ({ shortcuts = defaultShortcut, onShortcut }: UseShortcu
});
};
export const useMountShortcutKeys = () => {
const request = useRequestCommand();
useShortcutKeys({
onShortcut: (shortcut) => {
request(shortcut.command);
},
});
};
export default useShortcutKeys;