diff --git a/packages/editor/src/components/ui/command-palette/index.tsx b/packages/editor/src/components/ui/command-palette/index.tsx index 3869bb9fc8..f83387d746 100644 --- a/packages/editor/src/components/ui/command-palette/index.tsx +++ b/packages/editor/src/components/ui/command-palette/index.tsx @@ -3,15 +3,15 @@ import type { AnyNodeId, LevelNode } from '@pascal-app/core' import { useScene } from '@pascal-app/core' import { markPerfAction, useViewer } from '@pascal-app/viewer' -import { Command, useCommandState } from 'cmdk' +import { Command, defaultFilter, useCommandState } from 'cmdk' import { ChevronRight, Search } from 'lucide-react' -import type { ReactNode } from 'react' -import { useEffect, useState } from 'react' +import type { ReactNode, RefObject } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { create } from 'zustand' import { useShallow } from 'zustand/shallow' import { Dialog, DialogContent, DialogTitle } from './../../../components/ui/primitives/dialog' import { getLevelDisplayName } from '@pascal-app/core' -import { useCommandRegistry } from '../../../store/use-command-registry' +import { type CommandAction, useCommandRegistry } from '../../../store/use-command-registry' import { usePaletteViewRegistry } from '../../../store/use-palette-view-registry' // --------------------------------------------------------------------------- @@ -54,6 +54,28 @@ function resolve(value: string | (() => string)): string { return typeof value === 'function' ? value() : value } +type ActionGroup = [group: string, actions: CommandAction[]] + +/** + * The registered actions by group, in the order they were registered. A search puts the group + * holding the best match first: cmdk ranks the items inside a group but leaves the groups where + * they are, so a strong match could sit below a screen of weak ones. + */ +export function rankedGroups(actions: CommandAction[], search: string): ActionGroup[] { + const groups = new Map() + for (const action of actions) { + groups.set(action.group, [...(groups.get(action.group) ?? []), action]) + } + const ordered: ActionGroup[] = [...groups] + if (!search) return ordered + const bestMatch = ([, list]: ActionGroup) => + Math.max(...list.map((action) => defaultFilter(resolve(action.label), search, action.keywords))) + return ordered + .map((entry) => ({ entry, score: bestMatch(entry) })) + .sort((a, b) => b.score - a.score) + .map(({ entry }) => entry) +} + function Shortcut({ keys }: { keys: string[] }) { return ( @@ -162,6 +184,20 @@ export interface CommandPaletteEmptyAction { onSelect: (query: string) => void } +/** + * A search selects the first item; this scrolls the list back to the top to show it. cmdk scrolls + * before the list re-renders the selection, so once a search has moved the groups it scrolls to + * wherever the item selected before went. Rendered after the list, whose ref is set by then. + */ +function TopOnFirstItem({ list }: { list: RefObject }) { + const value = useCommandState((s) => s.value) + useLayoutEffect(() => { + const first = list.current?.querySelector('[cmdk-item]:not([aria-disabled="true"])') + if (value && first?.getAttribute('aria-selected') === 'true') list.current?.scrollTo({ top: 0 }) + }, [list, value]) + return null +} + function EmptyActionItem({ action }: { action: CommandPaletteEmptyAction }) { const count = useCommandState((s) => s.filtered.count) const search = useCommandState((s) => s.search) @@ -193,6 +229,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm const [meta, setMeta] = useState('⌘') const [isFullscreen, setIsFullscreen] = useState(false) + const listRef = useRef(null) const page = pages[pages.length - 1] @@ -255,15 +292,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm }) } - // --------------------------------------------------------------------------- - // Group registered actions by group (preserving insertion order) - // --------------------------------------------------------------------------- - const grouped = actions.reduce>((acc, action) => { - const list = acc.get(action.group) ?? [] - list.push(action) - acc.set(action.group, list) - return acc - }, new Map()) + const grouped = rankedGroups(actions, inputValue) const onClose = () => setOpen(false) const onBack = () => { @@ -326,7 +355,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm /> - + {(!emptyAction || page) && ( No commands found. @@ -344,7 +373,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm {/* ── Root view: render from registry ───────────────────────── */} {!page && - Array.from(grouped.entries()).map(([group, groupActions]) => ( + grouped.map(([group, groupActions]) => ( {groupActions.map((action) => ( )} + {/* Footer hint */}
diff --git a/packages/editor/src/components/ui/command-palette/ranked-groups.test.ts b/packages/editor/src/components/ui/command-palette/ranked-groups.test.ts new file mode 100644 index 0000000000..aaf16ccccc --- /dev/null +++ b/packages/editor/src/components/ui/command-palette/ranked-groups.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test' +import { rankedGroups } from '.' + +const action = (group: string, label: string, keywords?: string[]) => ({ + id: label, + group, + label, + keywords, + execute: () => {}, +}) + +// What a host's palette holds: weak fuzzy matches for "theme" registered before the strong one. +const actions = [ + action('Generate', 'Generate house from template: The Poppy', ['template', 'adu', 'poppy']), + action('Viewer Controls', 'Switch to Solid', ['solid', 'shading', 'render', 'mode']), + action('View', 'Use dark theme', ['theme', 'appearance', 'interface']), + action('Export & Share', 'Take Screenshot', ['screenshot', 'capture', 'image']), +] + +const order = (search: string) => rankedGroups(actions, search).map(([group]) => group) + +describe('command palette groups', () => { + test('a search lists the group with the best match first', () => { + expect(order('theme')[0]).toBe('View') + expect(order('solid')[0]).toBe('Viewer Controls') + }) + + test('with no search the groups keep the order they were registered in', () => { + expect(order('')).toEqual(['Generate', 'Viewer Controls', 'View', 'Export & Share']) + }) +})