refactor command palette component to comand feature instead of menu feature
This commit is contained in:
parent
34b30b2bc2
commit
ddbcabcecf
6 changed files with 252 additions and 65 deletions
|
@ -11,7 +11,7 @@ interface ContextMenuType {
|
||||||
|
|
||||||
const ContextMenu = createContext<ContextMenuType>()
|
const ContextMenu = createContext<ContextMenuType>()
|
||||||
|
|
||||||
const Root: ParentComponent<{ commands: CommandType<any[]>[] }> = (props) => {
|
const Root: ParentComponent<{ commands: CommandType[] }> = (props) => {
|
||||||
const [target, setTarget] = createSignal<HTMLElement>();
|
const [target, setTarget] = createSignal<HTMLElement>();
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
|
|
|
@ -1,40 +1,48 @@
|
||||||
import { Accessor, children, Component, createContext, createEffect, createMemo, For, JSX, ParentComponent, ParentProps, Show, useContext } from 'solid-js';
|
import { Accessor, children, Component, createContext, createEffect, createMemo, For, JSX, ParentComponent, ParentProps, Show, useContext } from 'solid-js';
|
||||||
import { Dictionary, DictionaryKey, useI18n } from '../i18n';
|
import { Dictionary, DictionaryKey, useI18n } from '../i18n';
|
||||||
|
import { createStore, produce } from 'solid-js/store';
|
||||||
|
|
||||||
interface CommandContextType {
|
interface CommandContextType {
|
||||||
|
readonly commands: Accessor<CommandType[]>;
|
||||||
set(commands: CommandType<any>[]): void;
|
set(commands: CommandType<any>[]): void;
|
||||||
addContextualArguments<T extends (...args: any[]) => any = any>(command: CommandType<T>, target: EventTarget, args: Accessor<Parameters<T>>): 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;
|
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>();
|
const CommandContext = createContext<CommandContextType>();
|
||||||
|
|
||||||
export const useCommands = () => useContext(CommandContext);
|
export const useCommands = () => useContext(CommandContext);
|
||||||
|
|
||||||
const Root: ParentComponent<{ commands: CommandType[] }> = (props) => {
|
const Root: ParentComponent<{ commands: CommandType[] }> = (props) => {
|
||||||
// const commands = () => props.commands ?? [];
|
const [store, setStore] = createStore<CommandContextStateType>({ commands: [], contextualArguments: new Map() });
|
||||||
const contextualArguments = new Map<CommandType, WeakMap<EventTarget, Accessor<any[]>>>();
|
|
||||||
const commands = new Set<CommandType<any>>();
|
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
set(c: CommandType<any>[]): void {
|
commands: createMemo(() => store.commands),
|
||||||
for (const command of c) {
|
|
||||||
commands.add(command);
|
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 {
|
addContextualArguments<T extends (...args: any[]) => any = any>(command: CommandType<T>, target: EventTarget, args: Accessor<Parameters<T>>): void {
|
||||||
if (contextualArguments.has(command) === false) {
|
setStore('contextualArguments', prev => {
|
||||||
contextualArguments.set(command, new WeakMap());
|
if (prev.has(command) === false) {
|
||||||
}
|
prev.set(command, new WeakMap());
|
||||||
|
}
|
||||||
|
|
||||||
contextualArguments.get(command)?.set(target, args);
|
prev.get(command)?.set(target, args);
|
||||||
|
|
||||||
|
return new Map(prev);
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
execute<T extends (...args: any[]) => any = any>(command: CommandType<T>, event: Event): boolean | undefined {
|
execute<T extends (...args: any[]) => any = any>(command: CommandType<T>, event: Event): boolean | undefined {
|
||||||
const args = ((): Parameters<T> => {
|
const args = ((): Parameters<T> => {
|
||||||
|
const contexts = store.contextualArguments.get(command);
|
||||||
const contexts = contextualArguments.get(command);
|
|
||||||
|
|
||||||
if (contexts === undefined) {
|
if (contexts === undefined) {
|
||||||
return [] as any;
|
return [] as any;
|
||||||
|
@ -72,7 +80,7 @@ const Root: ParentComponent<{ commands: CommandType[] }> = (props) => {
|
||||||
(e.metaKey ? 1 : 0) << 2 |
|
(e.metaKey ? 1 : 0) << 2 |
|
||||||
(e.altKey ? 1 : 0) << 3;
|
(e.altKey ? 1 : 0) << 3;
|
||||||
|
|
||||||
const command = commands.values().find(c => c.shortcut?.key === key && (c.shortcut.modifier === undefined || c.shortcut.modifier === modifiers));
|
const command = store.commands.values().find(c => c.shortcut?.key === key && (c.shortcut.modifier === undefined || c.shortcut.modifier === modifiers));
|
||||||
|
|
||||||
if (command === undefined) {
|
if (command === undefined) {
|
||||||
return;
|
return;
|
||||||
|
@ -86,9 +94,9 @@ const Root: ParentComponent<{ commands: CommandType[] }> = (props) => {
|
||||||
</CommandContext.Provider>;
|
</CommandContext.Provider>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Add: Component<{ command: CommandType<any> } | { commands: CommandType<any>[] }> = (props) => {
|
const Add: Component<{ command: CommandType, commands: undefined } | { commands: CommandType[] }> = (props) => {
|
||||||
const context = useCommands();
|
const context = useCommands();
|
||||||
const commands = createMemo<CommandType<any>[]>(() => props.commands ?? [props.command]);
|
const commands = createMemo<CommandType[]>(() => props.commands ?? [props.command]);
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
context?.set(commands());
|
context?.set(commands());
|
||||||
|
@ -152,7 +160,7 @@ export enum Modifier {
|
||||||
Alt = 1 << 3,
|
Alt = 1 << 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandType<T extends (...args: any[]) => any = any> {
|
export interface CommandType<T extends (...args: any[]) => any = (...args: any[]) => any> {
|
||||||
(...args: Parameters<T>): Promise<ReturnType<T>>;
|
(...args: Parameters<T>): Promise<ReturnType<T>>;
|
||||||
label: DictionaryKey;
|
label: DictionaryKey;
|
||||||
shortcut?: {
|
shortcut?: {
|
||||||
|
@ -194,4 +202,7 @@ export const createCommand = <T extends (...args: any[]) => any>(label: Dictiona
|
||||||
|
|
||||||
export const noop = createCommand('noop' as any, () => { });
|
export const noop = createCommand('noop' as any, () => { });
|
||||||
|
|
||||||
export { Context } from './contextMenu';
|
|
||||||
|
export type { CommandPaletteApi } from './palette';
|
||||||
|
export { Context } from './contextMenu';
|
||||||
|
export { CommandPalette } from './palette';
|
43
src/features/command/palette.module.css
Normal file
43
src/features/command/palette.module.css
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
.commandPalette {
|
||||||
|
display: none;
|
||||||
|
background-color: var(--surface-700);
|
||||||
|
color: var(--text-1);
|
||||||
|
gap: var(--padding-m);
|
||||||
|
padding: var(--padding-l);
|
||||||
|
border: 1px solid var(--surface-500);
|
||||||
|
|
||||||
|
&[open] {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::backdrop {
|
||||||
|
background-color: color(from var(--surface-700) xyz x y z / .2);
|
||||||
|
backdrop-filter: blur(.25em);
|
||||||
|
pointer-events: all !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--padding-m);
|
||||||
|
|
||||||
|
& > input {
|
||||||
|
background-color: var(--surface-600);
|
||||||
|
color: var(--text-1);
|
||||||
|
border: none;
|
||||||
|
padding: var(--padding-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > output {
|
||||||
|
display: contents;
|
||||||
|
color: var(--text-2);
|
||||||
|
|
||||||
|
& > .selected {
|
||||||
|
background-color: color(from var(--info) xyz x y z / .5);
|
||||||
|
}
|
||||||
|
|
||||||
|
& b {
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
177
src/features/command/palette.tsx
Normal file
177
src/features/command/palette.tsx
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
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 = useCommands();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
console.log('context is missing...');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => t(item.label) as string} context={setSearch} onSubmit={onSubmit}>{
|
||||||
|
(item, ctx) => <For each={(t(item.label) as string).split(new RegExp(ctx.filter(), 'i'))}>{(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 [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).toLowerCase().includes(search.toLowerCase()));
|
||||||
|
});
|
||||||
|
|
||||||
|
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}`} 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 class={`${index() === selected() ? css.selected : ''}`}>{props.children(result, ctx)}</div>
|
||||||
|
}</For>
|
||||||
|
</output>
|
||||||
|
</form>
|
||||||
|
</search>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let keyCounter = 0;
|
||||||
|
const createUniqueId = () => `key-${keyCounter++}`;
|
||||||
|
|
||||||
|
declare module "solid-js" {
|
||||||
|
namespace JSX {
|
||||||
|
interface HTMLAttributes<T> {
|
||||||
|
anchor?: string | undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -71,48 +71,4 @@
|
||||||
|
|
||||||
:popover-open + .item {
|
:popover-open + .item {
|
||||||
background-color: var(--surface-500);
|
background-color: var(--surface-500);
|
||||||
}
|
|
||||||
|
|
||||||
.commandPalette {
|
|
||||||
display: none;
|
|
||||||
background-color: var(--surface-700);
|
|
||||||
color: var(--text-1);
|
|
||||||
gap: var(--padding-m);
|
|
||||||
padding: var(--padding-l);
|
|
||||||
border: 1px solid var(--surface-500);
|
|
||||||
|
|
||||||
&[open] {
|
|
||||||
display: grid;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::backdrop {
|
|
||||||
background-color: color(from var(--surface-700) xyz x y z / .2);
|
|
||||||
backdrop-filter: blur(.25em);
|
|
||||||
pointer-events: all !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--padding-m);
|
|
||||||
|
|
||||||
& > input {
|
|
||||||
background-color: var(--surface-600);
|
|
||||||
color: var(--text-1);
|
|
||||||
border: none;
|
|
||||||
padding: var(--padding-m);
|
|
||||||
}
|
|
||||||
|
|
||||||
& > output {
|
|
||||||
display: contents;
|
|
||||||
color: var(--text-2);
|
|
||||||
|
|
||||||
& > .selected {
|
|
||||||
background-color: color(from var(--info) xyz x y z / .5);
|
|
||||||
}
|
|
||||||
|
|
||||||
& b {
|
|
||||||
color: var(--text-1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -1,9 +1,9 @@
|
||||||
import { Link, Meta, Title } from "@solidjs/meta";
|
import { Link, Meta, Title } from "@solidjs/meta";
|
||||||
import { Component, createMemo, createSignal, ErrorBoundary, ParentProps, Show } from "solid-js";
|
import { Component, createMemo, createSignal, ErrorBoundary, ParentProps, Show } from "solid-js";
|
||||||
import { FilesProvider } from "~/features/file";
|
import { FilesProvider } from "~/features/file";
|
||||||
import { CommandPalette, CommandPaletteApi, Menu, MenuProvider } from "~/features/menu";
|
import { Menu, MenuProvider } from "~/features/menu";
|
||||||
import { A, RouteDefinition, useBeforeLeave } from "@solidjs/router";
|
import { A, RouteDefinition, useBeforeLeave } from "@solidjs/router";
|
||||||
import { createCommand, Modifier } from "~/features/command";
|
import { CommandPalette, CommandPaletteApi, createCommand, Modifier } from "~/features/command";
|
||||||
import { ColorScheme, ColorSchemePicker, getState, useTheme } from "~/components/colorschemepicker";
|
import { ColorScheme, ColorSchemePicker, getState, useTheme } from "~/components/colorschemepicker";
|
||||||
import { getRequestEvent } from "solid-js/web";
|
import { getRequestEvent } from "solid-js/web";
|
||||||
import { HttpHeader } from "@solidjs/start";
|
import { HttpHeader } from "@solidjs/start";
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue