diff --git a/src/features/command/command.ts b/src/features/command/command.ts new file mode 100644 index 0000000..d7197ae --- /dev/null +++ b/src/features/command/command.ts @@ -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 any = (...args: any[]) => any> { + (...args: Parameters): Promise>; + label: DictionaryKey; + shortcut?: { + key: string; + modifier: Modifier; + }; + withLabel(label: string): CommandType; + with(this: (this: ThisParameterType, ...args: [...A, ...B]) => ReturnType, ...args: A): CommandType<(...args: B) => ReturnType>; +} + +export const createCommand = any>(label: DictionaryKey, command: T, shortcut?: CommandType['shortcut']): CommandType => { + return Object.defineProperties(((...args: Parameters) => 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(this: (this: ThisParameterType, ...args: [...A, ...B]) => ReturnType, ...args: A): CommandType<(...args: B) => ReturnType> { + return createCommand(label, command.bind(undefined, ...args), shortcut); + }, + configurable: false, + writable: false, + } + }); +}; + +export const noop = createCommand('noop' as any, () => { }); \ No newline at end of file diff --git a/src/features/command/context.tsx b/src/features/command/context.tsx new file mode 100644 index 0000000..c81bc48 --- /dev/null +++ b/src/features/command/context.tsx @@ -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; + 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 [store, setStore] = createStore({ commands: [], contextualArguments: new Map() }); + + const context = { + 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 { + setStore('contextualArguments', prev => { + if (prev.has(command) === false) { + prev.set(command, new WeakMap()); + } + + prev.get(command)?.set(target, args); + + return new Map(prev); + }) + }, + + execute any = any>(command: CommandType, event: Event): boolean | undefined { + const args = ((): Parameters => { + 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>; + + 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 +
{props.children}
+
; +}; + +const Add: Component<{ command: CommandType, commands: undefined } | { commands: CommandType[] }> = (props) => { + const context = useCommands(); + const commands = createMemo(() => props.commands ?? [props.command]); + + createEffect(() => { + context?.set(commands()); + }); + + return undefined; +}; + +const Context = any = any>(props: ParentProps<{ for: CommandType, with: Parameters }>): 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))} + + { + shortcut => { + const modifier = shortcut().modifier; + const modifierMap: Record = { + [Modifier.Shift]: 'Shft', + [Modifier.Control]: 'Ctrl', + [Modifier.Meta]: 'Meta', + [Modifier.Alt]: 'Alt', + }; + + return + typeof m === 'number').filter(m => modifier & m)}>{ + (m) => <>{modifierMap[m]}+ + } + {shortcut().key} + ; + } + } + ; +}; + +export const Command = { Root, Handle, Add, Context }; \ No newline at end of file diff --git a/src/features/command/contextMenu.tsx b/src/features/command/contextMenu.tsx index b605781..deea323 100644 --- a/src/features/command/contextMenu.tsx +++ b/src/features/command/contextMenu.tsx @@ -1,5 +1,5 @@ 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"; interface ContextMenuType { diff --git a/src/features/command/index.tsx b/src/features/command/index.tsx index 3504fb7..cddb340 100644 --- a/src/features/command/index.tsx +++ b/src/features/command/index.tsx @@ -1,208 +1,7 @@ -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 [store, setStore] = createStore({ commands: [], contextualArguments: new Map() }); - - const context = { - 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 { - setStore('contextualArguments', prev => { - if (prev.has(command) === false) { - prev.set(command, new WeakMap()); - } - - prev.get(command)?.set(target, args); - - return new Map(prev); - }) - }, - - execute any = any>(command: CommandType, event: Event): boolean | undefined { - const args = ((): Parameters => { - 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>; - - 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 -
{props.children}
-
; -}; - -const Add: Component<{ command: CommandType, commands: undefined } | { commands: CommandType[] }> = (props) => { - const context = useCommands(); - const commands = createMemo(() => props.commands ?? [props.command]); - - createEffect(() => { - context?.set(commands()); - }); - - return undefined; -}; - -const Context = any = any>(props: ParentProps<{ for: CommandType, with: Parameters }>): 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))} - - { - shortcut => { - const modifier = shortcut().modifier; - const modifierMap: Record = { - [Modifier.Shift]: 'Shft', - [Modifier.Control]: 'Ctrl', - [Modifier.Meta]: 'Meta', - [Modifier.Alt]: 'Alt', - }; - - return - typeof m === 'number').filter(m => modifier & m)}>{ - (m) => <>{modifierMap[m]}+ - } - {shortcut().key} - ; - } - } - ; -}; - -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 any = (...args: any[]) => any> { - (...args: Parameters): Promise>; - label: DictionaryKey; - shortcut?: { - key: string; - modifier: Modifier; - }; - withLabel(label: string): CommandType; - with
(this: (this: ThisParameterType, ...args: [...A, ...B]) => ReturnType, ...args: A): CommandType<(...args: B) => ReturnType>; -} - -export const createCommand = any>(label: DictionaryKey, command: T, shortcut?: CommandType['shortcut']): CommandType => { - return Object.defineProperties(((...args: Parameters) => 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(this: (this: ThisParameterType, ...args: [...A, ...B]) => ReturnType, ...args: A): CommandType<(...args: B) => ReturnType> { - return createCommand(label, command.bind(undefined, ...args), shortcut); - }, - configurable: false, - writable: false, - } - }); -}; - -export const noop = createCommand('noop' as any, () => { }); - - +export type { CommandType } from './command'; export type { CommandPaletteApi } from './palette'; + +export { createCommand, noop, Modifier } from './command'; +export { useCommands, Command } from './context'; export { Context } from './contextMenu'; export { CommandPalette } from './palette'; \ No newline at end of file diff --git a/src/features/command/palette.tsx b/src/features/command/palette.tsx index e433e70..084eeb3 100644 --- a/src/features/command/palette.tsx +++ b/src/features/command/palette.tsx @@ -1,7 +1,8 @@ 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 { CommandType } from "./command"; +import { useCommands } from "./context"; +import css from "./palette.module.css"; export interface CommandPaletteApi { readonly open: Accessor; @@ -168,10 +169,3 @@ function SearchableList(props: SearchableListProps): JSX.Element { 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.tsx b/src/features/menu/index.tsx index 346b587..ac31832 100644 --- a/src/features/menu/index.tsx +++ b/src/features/menu/index.tsx @@ -75,7 +75,7 @@ const useMenu = () => { return context; } -type ItemProps any> = { label: string, children: JSX.Element } | { command: CommandType }; +type ItemProps any> = { label: string, children: JSX.Element, command: undefined } | { command: CommandType }; function Item any>(props: ItemProps) { const id = createUniqueId(); @@ -187,166 +187,6 @@ const Mount: Component = (props) => { export const Menu = { Mount, Root, Item, Separator } as const; -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 = 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 setOpen(false)}> - item.label} 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 [input, setInput] = 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).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 -
- 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++}`; diff --git a/src/features/selectable/index.tsx b/src/features/selectable/index.tsx index fc1f5e4..a547011 100644 --- a/src/features/selectable/index.tsx +++ b/src/features/selectable/index.tsx @@ -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 { isServer } from "solid-js/web"; import css from "./index.module.css"; diff --git a/src/routes/(editor)/edit.tsx b/src/routes/(editor)/edit.tsx index 8235219..1bf8136 100644 --- a/src/routes/(editor)/edit.tsx +++ b/src/routes/(editor)/edit.tsx @@ -4,7 +4,7 @@ import { Sidebar } from "~/components/sidebar"; import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree } from "~/components/filetree"; import { Menu } from "~/features/menu"; 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 { Tab, Tabs } from "~/components/tabs"; import { isServer } from "solid-js/web";