From ddbcabcecf2bd3ae170c2360f04cd714078e6c29 Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Tue, 7 Jan 2025 10:07:05 +0100 Subject: [PATCH] refactor command palette component to comand feature instead of menu feature --- src/features/command/contextMenu.tsx | 2 +- src/features/command/index.tsx | 47 ++++--- src/features/command/palette.module.css | 43 ++++++ src/features/command/palette.tsx | 177 ++++++++++++++++++++++++ src/features/menu/index.module.css | 44 ------ src/routes/(editor).tsx | 4 +- 6 files changed, 252 insertions(+), 65 deletions(-) create mode 100644 src/features/command/palette.module.css create mode 100644 src/features/command/palette.tsx diff --git a/src/features/command/contextMenu.tsx b/src/features/command/contextMenu.tsx index 5b8aff0..b605781 100644 --- a/src/features/command/contextMenu.tsx +++ b/src/features/command/contextMenu.tsx @@ -11,7 +11,7 @@ interface ContextMenuType { const ContextMenu = createContext() -const Root: ParentComponent<{ commands: CommandType[] }> = (props) => { +const Root: ParentComponent<{ commands: CommandType[] }> = (props) => { const [target, setTarget] = createSignal(); const context = { diff --git a/src/features/command/index.tsx b/src/features/command/index.tsx index d0461c4..3504fb7 100644 --- a/src/features/command/index.tsx +++ b/src/features/command/index.tsx @@ -1,40 +1,48 @@ import { Accessor, children, Component, createContext, createEffect, createMemo, For, JSX, ParentComponent, ParentProps, Show, useContext } from 'solid-js'; import { Dictionary, DictionaryKey, useI18n } from '../i18n'; +import { createStore, produce } from 'solid-js/store'; interface CommandContextType { + readonly commands: Accessor; set(commands: CommandType[]): void; addContextualArguments any = any>(command: CommandType, target: EventTarget, args: Accessor>): void; execute any = any>(command: CommandType, event: Event): void; } +interface CommandContextStateType { + commands: CommandType[]; + contextualArguments: Map>>; +} + const CommandContext = createContext(); export const useCommands = () => useContext(CommandContext); const Root: ParentComponent<{ commands: CommandType[] }> = (props) => { - // const commands = () => props.commands ?? []; - const contextualArguments = new Map>>(); - const commands = new Set>(); + const [store, setStore] = createStore({ commands: [], contextualArguments: new Map() }); const context = { - set(c: CommandType[]): void { - for (const command of c) { - commands.add(command); - } + commands: createMemo(() => store.commands), + + set(commands: CommandType[]): void { + setStore('commands', existing => new Set([...existing, ...commands]).values().toArray()); }, addContextualArguments any = any>(command: CommandType, target: EventTarget, args: Accessor>): void { - if (contextualArguments.has(command) === false) { - contextualArguments.set(command, new WeakMap()); - } + setStore('contextualArguments', prev => { + 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 any = any>(command: CommandType, event: Event): boolean | undefined { const args = ((): Parameters => { - - const contexts = contextualArguments.get(command); + const contexts = store.contextualArguments.get(command); if (contexts === undefined) { return [] as any; @@ -72,7 +80,7 @@ const Root: ParentComponent<{ commands: CommandType[] }> = (props) => { (e.metaKey ? 1 : 0) << 2 | (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) { return; @@ -86,9 +94,9 @@ const Root: ParentComponent<{ commands: CommandType[] }> = (props) => { ; }; -const Add: Component<{ command: CommandType } | { commands: CommandType[] }> = (props) => { +const Add: Component<{ command: CommandType, commands: undefined } | { commands: CommandType[] }> = (props) => { const context = useCommands(); - const commands = createMemo[]>(() => props.commands ?? [props.command]); + const commands = createMemo(() => props.commands ?? [props.command]); createEffect(() => { context?.set(commands()); @@ -152,7 +160,7 @@ export enum Modifier { Alt = 1 << 3, } -export interface CommandType any = any> { +export interface CommandType any = (...args: any[]) => any> { (...args: Parameters): Promise>; label: DictionaryKey; shortcut?: { @@ -194,4 +202,7 @@ export const createCommand = any>(label: Dictiona export const noop = createCommand('noop' as any, () => { }); -export { Context } from './contextMenu'; \ No newline at end of file + +export type { CommandPaletteApi } from './palette'; +export { Context } from './contextMenu'; +export { CommandPalette } from './palette'; \ No newline at end of file diff --git a/src/features/command/palette.module.css b/src/features/command/palette.module.css new file mode 100644 index 0000000..7f3cf77 --- /dev/null +++ b/src/features/command/palette.module.css @@ -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); + } + } +} \ No newline at end of file diff --git a/src/features/command/palette.tsx b/src/features/command/palette.tsx new file mode 100644 index 0000000..e433e70 --- /dev/null +++ b/src/features/command/palette.tsx @@ -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; + show(): void; + hide(): void; +} + +export const CommandPalette: Component<{ api?: (api: CommandPaletteApi) => any, onSubmit?: SubmitHandler }> = (props) => { + const [open, setOpen] = createSignal(false); + const [root, setRoot] = createSignal(); + const [search, setSearch] = createSignal>(); + 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 setOpen(false)}> + t(item.label) as string} context={setSearch} onSubmit={onSubmit}>{ + (item, ctx) => {(part, index) => <> + {ctx.filter()} + {part} + } + } + ; +}; + +interface SubmitHandler { + (item: T): any; +} + +interface SearchContext { + readonly filter: Accessor; + readonly results: Accessor; + readonly value: Accessor; + searchFor(term: string): void; + clear(): void; +} + +interface SearchableListProps { + items: T[]; + title?: string; + keySelector(item: T): string; + filter?: (item: T, search: string) => boolean; + children(item: T, context: SearchContext): JSX.Element; + context?: (context: SearchContext) => any, + onSubmit?: SubmitHandler; +} + +function SearchableList(props: SearchableListProps): JSX.Element { + const [term, setTerm] = createSignal(''); + const [selected, setSelected] = createSignal(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 +
+ setTerm(e.target.value)} placeholder="start typing for command" autofocus autocomplete="off" enterkeyhint="go" /> + + + { + (result, index) =>
{props.children(result, ctx)}
+ }
+
+
+
; +}; + +let keyCounter = 0; +const createUniqueId = () => `key-${keyCounter++}`; + +declare module "solid-js" { + namespace JSX { + interface HTMLAttributes { + anchor?: string | undefined; + } + } +} diff --git a/src/features/menu/index.module.css b/src/features/menu/index.module.css index 9045266..7c205a7 100644 --- a/src/features/menu/index.module.css +++ b/src/features/menu/index.module.css @@ -71,48 +71,4 @@ :popover-open + .item { 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); - } - } } \ No newline at end of file diff --git a/src/routes/(editor).tsx b/src/routes/(editor).tsx index 608fe69..ce883c1 100644 --- a/src/routes/(editor).tsx +++ b/src/routes/(editor).tsx @@ -1,9 +1,9 @@ import { Link, Meta, Title } from "@solidjs/meta"; import { Component, createMemo, createSignal, ErrorBoundary, ParentProps, Show } from "solid-js"; 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 { createCommand, Modifier } from "~/features/command"; +import { CommandPalette, CommandPaletteApi, createCommand, Modifier } from "~/features/command"; import { ColorScheme, ColorSchemePicker, getState, useTheme } from "~/components/colorschemepicker"; import { getRequestEvent } from "solid-js/web"; import { HttpHeader } from "@solidjs/start";