From e363ee1844d9950f424a1605d116df9cde931514 Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Tue, 22 Oct 2024 13:57:49 +0200 Subject: [PATCH] implemented a quick and dirty command palette --- src/features/menu/index.module.css | 44 ++++++ src/features/menu/index.tsx | 231 ++++++++++++++++++++++++++--- src/routes/(editor).tsx | 36 +++-- src/routes/(editor)/edit.tsx | 27 +++- 4 files changed, 302 insertions(+), 36 deletions(-) diff --git a/src/features/menu/index.module.css b/src/features/menu/index.module.css index 6a35e77..9f6cecc 100644 --- a/src/features/menu/index.module.css +++ b/src/features/menu/index.module.css @@ -55,4 +55,48 @@ :popover-open + .item { background-color: var(--surface-4); +} + +.commandPalette { + display: none; + background-color: var(--surface-1); + color: var(--text-1); + gap: var(--padding-m); + padding: var(--padding-l); + border: 1px solid var(--surface-3); + + &[open] { + display: grid; + } + + &::backdrop { + background-color: color(from var(--surface-1) 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-2); + 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/menu/index.tsx b/src/features/menu/index.tsx index ae6c474..807da9a 100644 --- a/src/features/menu/index.tsx +++ b/src/features/menu/index.tsx @@ -1,12 +1,13 @@ -import { Accessor, Component, For, JSX, Match, ParentComponent, Setter, Show, Switch, children, createContext, createEffect, createMemo, createSignal, createUniqueId, mergeProps, onCleanup, onMount, splitProps, useContext } from "solid-js"; +import { Accessor, Component, For, JSX, ParentComponent, Setter, Show, children, createContext, createEffect, createMemo, createSignal, createUniqueId, mergeProps, onCleanup, onMount, useContext } from "solid-js"; import { Portal } from "solid-js/web"; import { createStore } from "solid-js/store"; import { CommandType, Command } from "../command"; import css from "./index.module.css"; +import { join } from "vinxi/dist/types/lib/path"; export interface MenuContextType { - ref: Accessor; - setRef: Setter; + ref: Accessor; + setRef: Setter; addItems(items: (Item | ItemWithChildren)[]): void; items: Accessor<(Item | ItemWithChildren)[]>; @@ -14,12 +15,14 @@ export interface MenuContextType { }; export interface Item { + kind: 'leaf'; id: string; label: string; command: CommandType; } export interface ItemWithChildren { + kind: 'node'; id: string; label: string; children: Item[]; @@ -27,23 +30,34 @@ export interface ItemWithChildren { const MenuContext = createContext(); -export const MenuProvider: ParentComponent = (props) => { - const [ref, setRef] = createSignal(); - const [_items, setItems] = createSignal }>>(new Map()); - +export const MenuProvider: ParentComponent<{ commands?: CommandType[] }> = (props) => { + const [ref, setRef] = createSignal(); const [store, setStore] = createStore<{ items: Record }>({ items: {} }); - const addItems = (items: (Item | ItemWithChildren)[]) => setStore('items', values => { - for (const item of items) { - values[item.id] = item; - } + const ctx = { + ref, + setRef, + addItems(items: (Item | ItemWithChildren)[]) { + return setStore('items', values => { + for (const item of items) { + values[item.id] = item; + } - return values; - }); - const items = () => Object.values(store.items); - const commands = () => Object.values(store.items).map(item => item.children?.map(c => c.command) ?? item.command).flat(); + return values; + }) + }, + items() { + return Object.values(store.items); + }, + commands() { + return Object.values(store.items) + .map(item => item.kind === 'node' ? item.children.map(c => c.command) : item.command) + .flat() + .concat(props.commands ?? []); + }, + }; - return {props.children}; + return {props.children}; } const useMenu = () => { @@ -62,13 +76,14 @@ const Item: Component = (props) => { const id = createUniqueId(); if (props.command) { - return mergeProps(props, { id }) as unknown as JSX.Element; + return mergeProps(props, { id, kind: 'leaf' }) as unknown as JSX.Element; } const childItems = children(() => props.children); return mergeProps(props, { id, + kind: 'node', get children() { return childItems.toArray(); } @@ -192,4 +207,184 @@ export const asMenuRoot = (element: Element) => { menu.setRef(element); }; -export const Menu = { Root, Item } as const; \ No newline at end of file +export const Menu = { Root, Item } 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(); + } + }); + + // temp debug code + createEffect(() => { + search()?.searchFor('c'); + setOpen(true); + }); + + const onSubmit = (command: CommandType) => { + setOpen(false); + props.onSubmit?.(command); + + command(); + }; + + return setOpen(false)}> + items={context.commands()} keySelector={item => 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[]; + 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(); + 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(() => { + const index = selected(); + + if (index === undefined) { + return undefined; + } + + return results().at(index); + }); + const inputValue = createMemo(() => { + const v = value(); + + return v !== undefined ? props.keySelector(v) : term(); + }); + + const ctx = { + filter: term, + results, + value, + searchFor(term: string) { + setTerm(term); + }, + clear() { + setTerm(''); + setSelected(undefined); + }, + }; + + createEffect(() => { + props.context?.(ctx); + }); + + createEffect(() => { + const length = results().length - 1; + + setSelected(current => current !== undefined ? Math.min(current, length) : undefined); + }); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowUp') { + setSelected(current => current !== undefined && current > 0 ? current - 1 : undefined); + + e.preventDefault(); + } + + if (e.key === 'ArrowDown') { + setSelected(current => current !== undefined ? Math.min(results().length - 1, current + 1) : 0); + + e.preventDefault(); + } + }; + + const onSubmit = (e: SubmitEvent) => { + e.preventDefault(); + + if (selected() === undefined && term() !== '') { + setSelected(0); + } + + const v = value(); + + if (v === undefined) { + return; + } + + ctx.clear(); + props.onSubmit?.(v); + }; + + return
+ setTerm(e.target.value)} placeholder="start typing for command" autofocus /> + + + { + (result, index) =>
{props.children(result, ctx)}
+ }
+
+
; +}; diff --git a/src/routes/(editor).tsx b/src/routes/(editor).tsx index 22e6fcd..2554f22 100644 --- a/src/routes/(editor).tsx +++ b/src/routes/(editor).tsx @@ -1,27 +1,39 @@ import { Title } from "@solidjs/meta"; -import { ParentProps, Show } from "solid-js"; +import { Component, createEffect, createMemo, createSignal, For, ParentProps, Show } from "solid-js"; import { BsTranslate } from "solid-icons/bs"; import { FilesProvider } from "~/features/file"; -import { MenuProvider, asMenuRoot } from "~/features/menu"; +import { CommandPalette, CommandPaletteApi, MenuProvider, asMenuRoot, useMenu } from "~/features/menu"; import { isServer } from "solid-js/web"; import { A } from "@solidjs/router"; +import { createCommand, Modifier } from "~/features/command"; asMenuRoot // prevents removal of import export default function Editor(props: ParentProps) { - const supported = isServer || typeof window.showDirectoryPicker === 'function'; + const [commandPalette, setCommandPalette] = createSignal(); - return + const supported = isServer || typeof window.showDirectoryPicker === 'function'; + const commands = [ + createCommand('open command palette', () => { + commandPalette()?.show(); + }, { key: 'p', modifier: Modifier.Control | Modifier.Shift }), + ]; + + return Translation-Tool - +
+ - too bad, so sad. Your browser does not support the File Access API}> - - {props.children} - - + too bad, so sad. Your browser does not support the File Access API}> + + {props.children} + + +
+ +
} diff --git a/src/routes/(editor)/edit.tsx b/src/routes/(editor)/edit.tsx index c06189d..67a5004 100644 --- a/src/routes/(editor)/edit.tsx +++ b/src/routes/(editor)/edit.tsx @@ -158,12 +158,8 @@ export default function Edit(props: ParentProps) { const entry = _entries.get(key); const localEntry = entry?.[lang]; - console.log(entry, localEntry); - - // TODO :: this is not really a matrix, we should resolve the file when one does not exist - // - // happy path :: When we do have both an entry and localEntry and the localEntry has an id and that file is found - + // TODO :: try to resolve to a file + // // | | entry | localEntry | id | file | // |---|-------!------------|----!------! // | 1 | x | x | x | x | @@ -171,11 +167,30 @@ export default function Edit(props: ParentProps) { // | 3 | x | x | | | // | 4 | x | | | | // | 5 | | | | | + // + // 1. happy path + // 2. weird edge case + // 3. this language never had a file before. peek at at another language and create a new file with a mathing path + // 4. error? + // 5. error? if (!localEntry) { throw new Error('invalid edge case???'); } + if (localEntry.id === undefined) { + const [, alternativeLocalEntry] = Object.entries(entry).find(([l, e]) => l !== lang && e.id !== undefined) ?? []; + + if (alternativeLocalEntry === undefined) { + // unable to find alternative. show a picker instead? + return; + } + + const file = findFile(tree(), alternativeLocalEntry.id); + + console.log('alt', file); + } + const file = findFile(tree(), localEntry.id); const fileExists = file !== undefined;