import { useCallback, useEffect, useMemo, useRef, type KeyboardEvent } from "react"; import { Command, MagnifyingGlass } from "@phosphor-icons/react"; import { commandIds } from "@commands/ids"; import type { Artboard } from "@core/artboard"; import type { ImageDocument } from "@core/document"; import type { GenerationState, SelectionState, ViewportState, CommandPaletteState } from "@editor/state"; import type { AppStore } from "@editor/store"; import type { ToolState } from "@editor/tools"; import { createDocumentReadIndex } from "@editor/document-indexes"; import { createPaletteItems, type PaletteItem } from "./paletteItems"; export type CommandPaletteProps = { state: CommandPaletteState; document: ImageDocument; selection: SelectionState; viewport: ViewportState; tools: ToolState; generation: GenerationState; layersOpen: boolean; activeArtboard?: Artboard; dispatch: AppStore["dispatch"]; openFilePicker: () => void; openGenerate: () => void; openLayers: () => void; closeLayers: () => void; }; export function CommandPalette({ state, document, selection, viewport, tools, generation, layersOpen, activeArtboard, dispatch, openFilePicker, openGenerate, openLayers, closeLayers, }: CommandPaletteProps) { const inputRef = useRef(null); const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]); const activeArtboardId = selection.artboardId ?? document.artboards[0]?.id; const selectedLayerId = selection.layerIds[0]; const selectedLayer = selectedLayerId ? documentIndex.layerInfoById.get(selectedLayerId) : undefined; const canGroup = Boolean(selection.artboardId && selection.layerIds.length > 0); const canUngroup = selectedLayer?.layer.type === "group"; const items = useMemo( () => createPaletteItems({ document, documentIndex, activeArtboard, activeArtboardId, selectedLayer, canGroup, canUngroup, selection, viewport, tools, generation, layersOpen, dispatch, openFilePicker, openGenerate, openLayers, closeLayers, }), [ activeArtboard, activeArtboardId, canGroup, canUngroup, closeLayers, dispatch, document, documentIndex, generation, layersOpen, openFilePicker, openGenerate, openLayers, selectedLayer, selection, tools, viewport, ], ); const filteredItems = useMemo(() => filterItems(items, state.query), [items, state.query]); const activeIndex = filteredItems.length > 0 ? Math.min(state.selectedIndex, filteredItems.length - 1) : 0; useEffect(() => { if (!state.open) return; const frame = window.requestAnimationFrame(() => inputRef.current?.focus()); return () => window.cancelAnimationFrame(frame); }, [state.open]); const close = useCallback(() => { dispatch(commandIds.commandPaletteClose, undefined); }, [dispatch]); const runItem = useCallback( (item: PaletteItem | undefined) => { if (!item || item.disabled) return; dispatch(commandIds.commandPaletteClose, undefined); item.run(); }, [dispatch], ); const handleInputKeyDown = useCallback( (event: KeyboardEvent) => { event.stopPropagation(); if (event.key === "Escape") { event.preventDefault(); close(); return; } if (event.key === "ArrowDown") { event.preventDefault(); const nextIndex = filteredItems.length > 0 ? (activeIndex + 1) % filteredItems.length : 0; dispatch(commandIds.commandPaletteSetSelectedIndex, { selectedIndex: nextIndex }); return; } if (event.key === "ArrowUp") { event.preventDefault(); const nextIndex = filteredItems.length > 0 ? (activeIndex - 1 + filteredItems.length) % filteredItems.length : 0; dispatch(commandIds.commandPaletteSetSelectedIndex, { selectedIndex: nextIndex }); return; } if (event.key === "Enter") { event.preventDefault(); runItem(filteredItems[activeIndex]); } }, [activeIndex, close, dispatch, filteredItems, runItem], ); if (!state.open) return null; return (
{ if (event.target === event.currentTarget) close(); }} >
dispatch(commandIds.commandPaletteSetQuery, { query: event.currentTarget.value })} onKeyDown={handleInputKeyDown} placeholder="Search commands" aria-label="Search commands" className="h-full min-w-0 flex-1 bg-transparent text-base text-white outline-none placeholder:text-white/30" /> K
{filteredItems.length > 0 ? ( filteredItems.map((item, index) => { const active = index === activeIndex; return ( ); }) ) : (
No commands
)}
); } function filterItems(items: PaletteItem[], query: string) { const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean); if (terms.length === 0) return items; return items.filter((item) => { const haystack = [item.title, item.subtitle, item.section, ...(item.keywords ?? [])].filter(Boolean).join(" ").toLowerCase(); return terms.every((term) => haystack.includes(term)); }); } function paletteItemClass(active: boolean, disabled: boolean) { const base = "flex min-h-14 w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition focus:outline-none"; if (disabled) return `${base} cursor-not-allowed text-white/30 opacity-45`; return active ? `${base} bg-white text-black` : `${base} text-white/75 hover:bg-white/[0.08] hover:text-white`; } function paletteIconClass(active: boolean) { return active ? "grid size-10 shrink-0 place-items-center rounded-xl bg-black/10 text-black/60" : "grid size-10 shrink-0 place-items-center rounded-xl bg-white/[0.06] text-white/60"; } function paletteSubtitleClass(active: boolean) { return active ? "block truncate text-xs text-black/55" : "block truncate text-xs text-white/40"; } function paletteSectionClass(active: boolean) { return active ? "shrink-0 rounded-full bg-black/10 px-2.5 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.14em] text-black/45" : "shrink-0 rounded-full bg-white/[0.06] px-2.5 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.14em] text-white/35"; }