refactor command feature

This commit is contained in:
Chris Kruining 2025-01-07 10:24:09 +01:00
parent 824d98b9c8
commit 6e17401992
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
8 changed files with 216 additions and 378 deletions

View file

@ -0,0 +1,51 @@
import { DictionaryKey } from "../i18n";
export enum Modifier {
None = 0,
Shift = 1 << 0,
Control = 1 << 1,
Meta = 1 << 2,
Alt = 1 << 3,
}
export interface CommandType<T extends (...args: any[]) => any = (...args: any[]) => any> {
(...args: Parameters<T>): Promise<ReturnType<T>>;
label: DictionaryKey;
shortcut?: {
key: string;
modifier: Modifier;
};
withLabel(label: string): CommandType<T>;
with<A extends any[], B extends any[]>(this: (this: ThisParameterType<T>, ...args: [...A, ...B]) => ReturnType<T>, ...args: A): CommandType<(...args: B) => ReturnType<T>>;
}
export const createCommand = <T extends (...args: any[]) => any>(label: DictionaryKey, command: T, shortcut?: CommandType['shortcut']): CommandType<T> => {
return Object.defineProperties(((...args: Parameters<T>) => command(...args)) as any, {
label: {
value: label,
configurable: false,
writable: false,
},
shortcut: {
value: shortcut ? { key: shortcut.key.toLowerCase(), modifier: shortcut.modifier } : undefined,
configurable: false,
writable: false,
},
withLabel: {
value(label: DictionaryKey) {
return createCommand(label, command, shortcut);
},
configurable: false,
writable: false,
},
with: {
value<A extends any[], B extends any[]>(this: (this: ThisParameterType<T>, ...args: [...A, ...B]) => ReturnType<T>, ...args: A): CommandType<(...args: B) => ReturnType<T>> {
return createCommand(label, command.bind(undefined, ...args), shortcut);
},
configurable: false,
writable: false,
}
});
};
export const noop = createCommand('noop' as any, () => { });

View file

@ -0,0 +1,154 @@
import { Accessor, children, Component, createContext, createEffect, createMemo, For, JSX, ParentComponent, ParentProps, Show, useContext } from 'solid-js';
import { useI18n } from '../i18n';
import { createStore } from 'solid-js/store';
import { CommandType, Modifier } from './command';
interface CommandContextType {
readonly commands: Accessor<CommandType[]>;
set(commands: CommandType<any>[]): void;
addContextualArguments<T extends (...args: any[]) => any = any>(command: CommandType<T>, target: EventTarget, args: Accessor<Parameters<T>>): void;
execute<T extends (...args: any[]) => any = any>(command: CommandType<T>, event: Event): void;
}
interface CommandContextStateType {
commands: CommandType[];
contextualArguments: Map<CommandType, WeakMap<EventTarget, Accessor<any[]>>>;
}
const CommandContext = createContext<CommandContextType>();
export const useCommands = () => useContext(CommandContext);
const Root: ParentComponent<{ commands: CommandType[] }> = (props) => {
const [store, setStore] = createStore<CommandContextStateType>({ commands: [], contextualArguments: new Map() });
const context = {
commands: createMemo(() => store.commands),
set(commands: CommandType<any>[]): void {
setStore('commands', existing => new Set([...existing, ...commands]).values().toArray());
},
addContextualArguments<T extends (...args: any[]) => any = any>(command: CommandType<T>, target: EventTarget, args: Accessor<Parameters<T>>): void {
setStore('contextualArguments', prev => {
if (prev.has(command) === false) {
prev.set(command, new WeakMap());
}
prev.get(command)?.set(target, args);
return new Map(prev);
})
},
execute<T extends (...args: any[]) => any = any>(command: CommandType<T>, event: Event): boolean | undefined {
const args = ((): Parameters<T> => {
const contexts = store.contextualArguments.get(command);
if (contexts === undefined) {
return [] as any;
}
const element = event.composedPath().find(el => contexts.has(el));
if (element === undefined) {
return [] as any;
}
const args = contexts.get(element)! as Accessor<Parameters<T>>;
return args();
})();
event.preventDefault();
event.stopPropagation();
command(...args);
return false;
},
};
createEffect(() => {
context.set(props.commands ?? []);
});
const listener = (e: KeyboardEvent) => {
const key = e.key.toLowerCase();
const modifiers =
(e.shiftKey ? 1 : 0) << 0 |
(e.ctrlKey ? 1 : 0) << 1 |
(e.metaKey ? 1 : 0) << 2 |
(e.altKey ? 1 : 0) << 3;
const command = store.commands.values().find(c => c.shortcut?.key === key && (c.shortcut.modifier === undefined || c.shortcut.modifier === modifiers));
if (command === undefined) {
return;
}
return context.execute(command, e);
};
return <CommandContext.Provider value={context}>
<div tabIndex={0} style="display: contents;" onKeyDown={listener}>{props.children}</div>
</CommandContext.Provider>;
};
const Add: Component<{ command: CommandType, commands: undefined } | { commands: CommandType[] }> = (props) => {
const context = useCommands();
const commands = createMemo<CommandType[]>(() => props.commands ?? [props.command]);
createEffect(() => {
context?.set(commands());
});
return undefined;
};
const Context = <T extends (...args: any[]) => any = any>(props: ParentProps<{ for: CommandType<T>, with: Parameters<T> }>): JSX.Element => {
const resolved = children(() => props.children);
const context = useCommands();
const args = createMemo(() => props.with);
createEffect(() => {
const children = resolved();
if (Array.isArray(children) || !(children instanceof Element)) {
return;
}
context?.addContextualArguments(props.for, children, args);
});
return <>{resolved()}</>;
};
const Handle: Component<{ command: CommandType }> = (props) => {
const { t } = useI18n();
return <>
{String(t(props.command.label))}
<Show when={props.command.shortcut}>{
shortcut => {
const modifier = shortcut().modifier;
const modifierMap: Record<number, string> = {
[Modifier.Shift]: 'Shft',
[Modifier.Control]: 'Ctrl',
[Modifier.Meta]: 'Meta',
[Modifier.Alt]: 'Alt',
};
return <samp>
<For each={Object.values(Modifier).filter((m): m is number => typeof m === 'number').filter(m => modifier & m)}>{
(m) => <><kbd>{modifierMap[m]}</kbd>+</>
}</For>
<kbd>{shortcut().key}</kbd>
</samp>;
}
}</Show>
</>;
};
export const Command = { Root, Handle, Add, Context };

View file

@ -1,5 +1,5 @@
import { Accessor, Component, createContext, createEffect, createMemo, createSignal, For, JSX, ParentComponent, splitProps, useContext } from "solid-js"; import { Accessor, Component, createContext, createEffect, createMemo, createSignal, For, JSX, ParentComponent, splitProps, useContext } from "solid-js";
import { CommandType } from "./index"; import { CommandType } from "./command";
import css from "./contextMenu.module.css"; import css from "./contextMenu.module.css";
interface ContextMenuType { interface ContextMenuType {

View file

@ -1,208 +1,7 @@
import { Accessor, children, Component, createContext, createEffect, createMemo, For, JSX, ParentComponent, ParentProps, Show, useContext } from 'solid-js'; export type { CommandType } from './command';
import { Dictionary, DictionaryKey, useI18n } from '../i18n';
import { createStore, produce } from 'solid-js/store';
interface CommandContextType {
readonly commands: Accessor<CommandType[]>;
set(commands: CommandType<any>[]): void;
addContextualArguments<T extends (...args: any[]) => any = any>(command: CommandType<T>, target: EventTarget, args: Accessor<Parameters<T>>): void;
execute<T extends (...args: any[]) => any = any>(command: CommandType<T>, event: Event): void;
}
interface CommandContextStateType {
commands: CommandType[];
contextualArguments: Map<CommandType, WeakMap<EventTarget, Accessor<any[]>>>;
}
const CommandContext = createContext<CommandContextType>();
export const useCommands = () => useContext(CommandContext);
const Root: ParentComponent<{ commands: CommandType[] }> = (props) => {
const [store, setStore] = createStore<CommandContextStateType>({ commands: [], contextualArguments: new Map() });
const context = {
commands: createMemo(() => store.commands),
set(commands: CommandType<any>[]): void {
setStore('commands', existing => new Set([...existing, ...commands]).values().toArray());
},
addContextualArguments<T extends (...args: any[]) => any = any>(command: CommandType<T>, target: EventTarget, args: Accessor<Parameters<T>>): void {
setStore('contextualArguments', prev => {
if (prev.has(command) === false) {
prev.set(command, new WeakMap());
}
prev.get(command)?.set(target, args);
return new Map(prev);
})
},
execute<T extends (...args: any[]) => any = any>(command: CommandType<T>, event: Event): boolean | undefined {
const args = ((): Parameters<T> => {
const contexts = store.contextualArguments.get(command);
if (contexts === undefined) {
return [] as any;
}
const element = event.composedPath().find(el => contexts.has(el));
if (element === undefined) {
return [] as any;
}
const args = contexts.get(element)! as Accessor<Parameters<T>>;
return args();
})();
event.preventDefault();
event.stopPropagation();
command(...args);
return false;
},
};
createEffect(() => {
context.set(props.commands ?? []);
});
const listener = (e: KeyboardEvent) => {
const key = e.key.toLowerCase();
const modifiers =
(e.shiftKey ? 1 : 0) << 0 |
(e.ctrlKey ? 1 : 0) << 1 |
(e.metaKey ? 1 : 0) << 2 |
(e.altKey ? 1 : 0) << 3;
const command = store.commands.values().find(c => c.shortcut?.key === key && (c.shortcut.modifier === undefined || c.shortcut.modifier === modifiers));
if (command === undefined) {
return;
}
return context.execute(command, e);
};
return <CommandContext.Provider value={context}>
<div tabIndex={0} style="display: contents;" onKeyDown={listener}>{props.children}</div>
</CommandContext.Provider>;
};
const Add: Component<{ command: CommandType, commands: undefined } | { commands: CommandType[] }> = (props) => {
const context = useCommands();
const commands = createMemo<CommandType[]>(() => props.commands ?? [props.command]);
createEffect(() => {
context?.set(commands());
});
return undefined;
};
const Context = <T extends (...args: any[]) => any = any>(props: ParentProps<{ for: CommandType<T>, with: Parameters<T> }>): JSX.Element => {
const resolved = children(() => props.children);
const context = useCommands();
const args = createMemo(() => props.with);
createEffect(() => {
const children = resolved();
if (Array.isArray(children) || !(children instanceof Element)) {
return;
}
context?.addContextualArguments(props.for, children, args);
});
return <>{resolved()}</>;
};
const Handle: Component<{ command: CommandType }> = (props) => {
const { t } = useI18n();
return <>
{String(t(props.command.label))}
<Show when={props.command.shortcut}>{
shortcut => {
const modifier = shortcut().modifier;
const modifierMap: Record<number, string> = {
[Modifier.Shift]: 'Shft',
[Modifier.Control]: 'Ctrl',
[Modifier.Meta]: 'Meta',
[Modifier.Alt]: 'Alt',
};
return <samp>
<For each={Object.values(Modifier).filter((m): m is number => typeof m === 'number').filter(m => modifier & m)}>{
(m) => <><kbd>{modifierMap[m]}</kbd>+</>
}</For>
<kbd>{shortcut().key}</kbd>
</samp>;
}
}</Show>
</>;
};
export const Command = { Root, Handle, Add, Context };
export enum Modifier {
None = 0,
Shift = 1 << 0,
Control = 1 << 1,
Meta = 1 << 2,
Alt = 1 << 3,
}
export interface CommandType<T extends (...args: any[]) => any = (...args: any[]) => any> {
(...args: Parameters<T>): Promise<ReturnType<T>>;
label: DictionaryKey;
shortcut?: {
key: string;
modifier: Modifier;
};
withLabel(label: string): CommandType<T>;
with<A extends any[], B extends any[]>(this: (this: ThisParameterType<T>, ...args: [...A, ...B]) => ReturnType<T>, ...args: A): CommandType<(...args: B) => ReturnType<T>>;
}
export const createCommand = <T extends (...args: any[]) => any>(label: DictionaryKey, command: T, shortcut?: CommandType['shortcut']): CommandType<T> => {
return Object.defineProperties(((...args: Parameters<T>) => command(...args)) as any, {
label: {
value: label,
configurable: false,
writable: false,
},
shortcut: {
value: shortcut ? { key: shortcut.key.toLowerCase(), modifier: shortcut.modifier } : undefined,
configurable: false,
writable: false,
},
withLabel: {
value(label: DictionaryKey) {
return createCommand(label, command, shortcut);
},
configurable: false,
writable: false,
},
with: {
value<A extends any[], B extends any[]>(this: (this: ThisParameterType<T>, ...args: [...A, ...B]) => ReturnType<T>, ...args: A): CommandType<(...args: B) => ReturnType<T>> {
return createCommand(label, command.bind(undefined, ...args), shortcut);
},
configurable: false,
writable: false,
}
});
};
export const noop = createCommand('noop' as any, () => { });
export type { CommandPaletteApi } from './palette'; export type { CommandPaletteApi } from './palette';
export { createCommand, noop, Modifier } from './command';
export { useCommands, Command } from './context';
export { Context } from './contextMenu'; export { Context } from './contextMenu';
export { CommandPalette } from './palette'; export { CommandPalette } from './palette';

View file

@ -1,7 +1,8 @@
import { Accessor, Component, createEffect, createMemo, createSignal, For, JSX, Show } from "solid-js"; import { Accessor, Component, createEffect, createMemo, createSignal, For, JSX, Show } from "solid-js";
import { CommandType, useCommands } from ".";
import css from "./palette.module.css";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { CommandType } from "./command";
import { useCommands } from "./context";
import css from "./palette.module.css";
export interface CommandPaletteApi { export interface CommandPaletteApi {
readonly open: Accessor<boolean>; readonly open: Accessor<boolean>;
@ -168,10 +169,3 @@ function SearchableList<T>(props: SearchableListProps<T>): JSX.Element {
let keyCounter = 0; let keyCounter = 0;
const createUniqueId = () => `key-${keyCounter++}`; const createUniqueId = () => `key-${keyCounter++}`;
declare module "solid-js" {
namespace JSX {
interface HTMLAttributes<T> {
anchor?: string | undefined;
}
}
}

View file

@ -75,7 +75,7 @@ const useMenu = () => {
return context; return context;
} }
type ItemProps<T extends (...args: any[]) => any> = { label: string, children: JSX.Element } | { command: CommandType<T> }; type ItemProps<T extends (...args: any[]) => any> = { label: string, children: JSX.Element, command: undefined } | { command: CommandType<T> };
function Item<T extends (...args: any[]) => any>(props: ItemProps<T>) { function Item<T extends (...args: any[]) => any>(props: ItemProps<T>) {
const id = createUniqueId(); const id = createUniqueId();
@ -187,166 +187,6 @@ const Mount: Component = (props) => {
export const Menu = { Mount, Root, Item, Separator } as const; export const Menu = { Mount, Root, Item, Separator } as const;
export interface CommandPaletteApi {
readonly open: Accessor<boolean>;
show(): void;
hide(): void;
}
export const CommandPalette: Component<{ api?: (api: CommandPaletteApi) => any, onSubmit?: SubmitHandler<CommandType> }> = (props) => {
const [open, setOpen] = createSignal<boolean>(false);
const [root, setRoot] = createSignal<HTMLDialogElement>();
const [search, setSearch] = createSignal<SearchContext<CommandType>>();
const context = useMenu();
const api = {
open,
show() {
setOpen(true);
},
hide() {
setOpen(false);
},
};
createEffect(() => {
props.api?.(api);
});
createEffect(() => {
const isOpen = open();
if (isOpen) {
search()?.clear();
root()?.showModal();
} else {
root()?.close();
}
});
const onSubmit = (command: CommandType) => {
setOpen(false);
props.onSubmit?.(command);
command();
};
return <dialog ref={setRoot} class={css.commandPalette} onClose={() => setOpen(false)}>
<SearchableList title="command palette" items={context.commands()} keySelector={item => item.label} context={setSearch} onSubmit={onSubmit}>{
(item, ctx) => <For each={item.label.split(ctx.filter())}>{
(part, index) => <>
<Show when={index() !== 0}><b>{ctx.filter()}</b></Show>
{part}
</>
}</For>
}</SearchableList>
</dialog>;
};
interface SubmitHandler<T> {
(item: T): any;
}
interface SearchContext<T> {
readonly filter: Accessor<string>;
readonly results: Accessor<T[]>;
readonly value: Accessor<T | undefined>;
searchFor(term: string): void;
clear(): void;
}
interface SearchableListProps<T> {
items: T[];
title?: string;
keySelector(item: T): string;
filter?: (item: T, search: string) => boolean;
children(item: T, context: SearchContext<T>): JSX.Element;
context?: (context: SearchContext<T>) => any,
onSubmit?: SubmitHandler<T>;
}
function SearchableList<T>(props: SearchableListProps<T>): JSX.Element {
const [term, setTerm] = createSignal<string>('');
const [input, setInput] = createSignal<HTMLInputElement>();
const [selected, setSelected] = createSignal<number>(0);
const id = createUniqueId();
const results = createMemo(() => {
const search = term();
if (search === '') {
return [];
}
return props.items.filter(item => props.filter ? props.filter(item, search) : props.keySelector(item).includes(search));
});
const value = createMemo(() => results().at(selected()));
const ctx = {
filter: term,
results,
value,
searchFor(term: string) {
setTerm(term);
},
clear() {
setTerm('');
setSelected(0);
},
};
createEffect(() => {
props.context?.(ctx);
});
createEffect(() => {
const length = results().length - 1;
setSelected(current => Math.min(current, length));
});
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowUp') {
setSelected(current => Math.max(0, current - 1));
e.preventDefault();
}
if (e.key === 'ArrowDown') {
setSelected(current => Math.min(results().length - 1, current + 1));
e.preventDefault();
}
};
const onSubmit = (e: SubmitEvent) => {
e.preventDefault();
const v = value();
if (v === undefined) {
return;
}
ctx.clear();
props.onSubmit?.(v);
};
return <search title={props.title}>
<form method="dialog" class={css.search} onkeydown={onKeyDown} onsubmit={onSubmit}>
<input id={`search-${id}`} ref={setInput} value={term()} oninput={(e) => setTerm(e.target.value)} placeholder="start typing for command" autofocus autocomplete="off" enterkeyhint="go" />
<output for={`search-${id}`}>
<For each={results()}>{
(result, index) => <div classList={{ [css.selected]: index() === selected() }}>{props.children(result, ctx)}</div>
}</For>
</output>
</form>
</search>;
};
let keyCounter = 0; let keyCounter = 0;
const createUniqueId = () => `key-${keyCounter++}`; const createUniqueId = () => `key-${keyCounter++}`;

View file

@ -1,4 +1,4 @@
import { Accessor, children, createContext, createEffect, createMemo, createRenderEffect, createSignal, onCleanup, onMount, ParentComponent, ParentProps, Setter, Signal, useContext } from "solid-js"; import { Accessor, children, createContext, createEffect, createMemo, createRenderEffect, createSignal, onCleanup, onMount, ParentComponent, ParentProps, Signal, useContext } from "solid-js";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import { isServer } from "solid-js/web"; import { isServer } from "solid-js/web";
import css from "./index.module.css"; import css from "./index.module.css";

View file

@ -4,7 +4,7 @@ import { Sidebar } from "~/components/sidebar";
import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree } from "~/components/filetree"; import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree } from "~/components/filetree";
import { Menu } from "~/features/menu"; import { Menu } from "~/features/menu";
import { Grid, load, useFiles } from "~/features/file"; import { Grid, load, useFiles } from "~/features/file";
import { Command, CommandType, Context, createCommand, Modifier, noop, useCommands } from "~/features/command"; import { Command, CommandType, Context, createCommand, Modifier } from "~/features/command";
import { Entry, GridApi } from "~/features/file/grid"; import { Entry, GridApi } from "~/features/file/grid";
import { Tab, Tabs } from "~/components/tabs"; import { Tab, Tabs } from "~/components/tabs";
import { isServer } from "solid-js/web"; import { isServer } from "solid-js/web";