import { useCallback, useEffect, useMemo, useRef, type KeyboardEvent, type ReactNode } from "react"; import { ArrowDown, ArrowUp, Command, CornersOut, Cursor, DownloadSimple, DropHalf, Eraser, Eye, EyeSlash, FolderOpen, FolderPlus, Hand, Lock, LockOpen, MagicWand, MagnifyingGlass, Minus, PaintBrush, Plus, Sparkle, Stack, Trash, } from "@phosphor-icons/react"; import { commandIds } from "@commands/ids"; import type { Artboard } from "@core/artboard"; import type { ImageDocument } from "@core/document"; import type { GenerationCompareMode, GenerationState, SelectionState, ViewportState, CommandPaletteState } from "@editor/state"; import type { AppStore } from "@editor/store"; import { availableToolIds, type GenerateMode, type ToolId, type ToolState } from "@editor/tools"; import { createDocumentReadIndex, type DocumentReadIndex, type IndexedLayerInfo } from "@editor/document-indexes"; import { downloadArtboardPng } from "./exportArtboardPng"; import { addArtboard, addEmptyLayer, addGroupLayer, deleteSelection, groupLayers, moveLayer } from "./layerActions"; import { labelForTool } from "./toolLabels"; 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; }; type PaletteItem = { id: string; title: string; section: string; subtitle?: string; keywords?: string[]; disabled?: boolean; icon: ReactNode; run: () => 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 createPaletteItems(options: { document: ImageDocument; documentIndex: DocumentReadIndex; activeArtboard?: Artboard; activeArtboardId?: string; selectedLayer?: IndexedLayerInfo; canGroup: boolean; canUngroup: boolean; selection: SelectionState; viewport: ViewportState; tools: ToolState; generation: GenerationState; layersOpen: boolean; dispatch: AppStore["dispatch"]; openFilePicker: () => void; openGenerate: () => void; openLayers: () => void; closeLayers: () => void; }): PaletteItem[] { const { document, documentIndex, activeArtboard, activeArtboardId, selectedLayer, canGroup, canUngroup, selection, viewport, tools, generation, layersOpen, dispatch, openFilePicker, openGenerate, openLayers, closeLayers, } = options; const hasCandidates = generation.candidates.length > 0; const items: PaletteItem[] = []; items.push( ...availableToolIds.map((tool) => ({ id: `tool-${tool}`, section: "Tools", title: `Switch to ${labelForTool(tool)}`, subtitle: tool === tools.activeTool ? "Current tool" : undefined, keywords: [tool], icon: toolIcon(tool), run: () => { if (tool === "generate") openGenerate(); else dispatch(commandIds.toolSetActive, { tool }); }, })), ); items.push( { id: "import-image", section: "File", title: "Import image", subtitle: "Add an image as a layer", keywords: ["open", "file", "layer"], icon: , run: openFilePicker, }, { id: "export-artboard", section: "File", title: "Export artboard as PNG", subtitle: activeArtboard ? activeArtboard.name : "No artboard selected", keywords: ["download", "png"], disabled: !activeArtboard, icon: , run: () => { if (activeArtboard) void downloadArtboardPng(activeArtboard, document.assets); }, }, ); items.push( { id: layersOpen ? "close-layers" : "open-layers", section: "Layers", title: layersOpen ? "Close layers panel" : "Open layers panel", keywords: ["panel", "stack"], icon: , run: layersOpen ? closeLayers : openLayers, }, { id: "add-artboard", section: "Layers", title: "Add artboard", icon: , run: () => addArtboard(document, dispatch), }, { id: "add-empty-layer", section: "Layers", title: "Add empty layer", subtitle: activeArtboardId ? undefined : "No artboard available", keywords: ["new", "raster"], disabled: !activeArtboardId, icon: , run: () => { if (activeArtboardId) addEmptyLayer(document, activeArtboardId, selectedLayer, dispatch); }, }, { id: "add-group", section: "Layers", title: "Add group", subtitle: activeArtboardId ? undefined : "No artboard available", disabled: !activeArtboardId, icon: , run: () => { if (activeArtboardId) addGroupLayer(activeArtboardId, dispatch); }, }, { id: "group-selection", section: "Layers", title: "Group selected layers", subtitle: canGroup ? `${selection.layerIds.length} selected` : "Select one or more layers", disabled: !canGroup, icon: , run: () => { if (selection.artboardId) groupLayers(selection.artboardId, selection.layerIds, dispatch); }, }, { id: "ungroup-selection", section: "Layers", title: "Ungroup selected group", subtitle: selectedLayer?.layer.name, disabled: !canUngroup || !selectedLayer, icon: , run: () => { if (selectedLayer?.layer.type === "group") dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id }); }, }, { id: "move-layer-up", section: "Layers", title: "Move selected layer up", subtitle: selectedLayer?.layer.name, disabled: !selectedLayer, icon: , run: () => { if (selectedLayer) moveLayer(documentIndex, selectedLayer, -1, dispatch); }, }, { id: "move-layer-down", section: "Layers", title: "Move selected layer down", subtitle: selectedLayer?.layer.name, disabled: !selectedLayer, icon: , run: () => { if (selectedLayer) moveLayer(documentIndex, selectedLayer, 1, dispatch); }, }, { id: "toggle-layer-visible", section: "Layers", title: selectedLayer?.layer.visible === false ? "Show selected layer" : "Hide selected layer", subtitle: selectedLayer?.layer.name, disabled: !selectedLayer, icon: selectedLayer?.layer.visible === false ? : , run: () => { if (selectedLayer) dispatch(commandIds.documentSetLayerVisible, { layerId: selectedLayer.layer.id, visible: !selectedLayer.layer.visible }); }, }, { id: "toggle-layer-lock", section: "Layers", title: selectedLayer?.layer.locked ? "Unlock selected layer" : "Lock selected layer", subtitle: selectedLayer?.layer.name, disabled: !selectedLayer, icon: selectedLayer?.layer.locked ? : , run: () => { if (selectedLayer) dispatch(commandIds.documentSetLayerLocked, { layerId: selectedLayer.layer.id, locked: !selectedLayer.layer.locked }); }, }, { id: "delete-selection", section: "Layers", title: selectedLayer ? "Delete selected layer" : "Delete selected artboard", subtitle: selectedLayer?.layer.name ?? activeArtboard?.name, disabled: !selectedLayer && !selection.artboardId, icon: , run: () => deleteSelection(selection, selectedLayer, dispatch), }, ); items.push( { id: "open-generate", section: "Generate", title: "Open generate panel", subtitle: tools.activeTool === "generate" ? "Current tool" : undefined, keywords: ["ai"], icon: , run: openGenerate, }, ...generateModeItems.map((modeItem) => ({ id: `generate-mode-${modeItem.mode}`, section: "Generate", title: modeItem.title, subtitle: tools.generate.mode === modeItem.mode ? "Current mode" : undefined, keywords: ["mode", modeItem.mode], icon: , run: () => { openGenerate(); dispatch(commandIds.toolSetGenerateSettings, { mode: modeItem.mode }); }, })), { id: "generate-random-seed", section: "Generate", title: "Use random seed", subtitle: tools.generate.seed === -1 ? "Current seed" : `Seed ${tools.generate.seed}`, keywords: ["seed"], icon: , run: () => dispatch(commandIds.toolSetGenerateSettings, { seed: -1 }), }, { id: "clear-generation-candidates", section: "Generate", title: "Clear candidates", subtitle: hasCandidates ? `${generation.candidates.length} candidate${generation.candidates.length === 1 ? "" : "s"}` : "No candidates", disabled: !hasCandidates, icon: , run: () => dispatch(commandIds.generationClearCandidates, undefined), }, ...generationCompareItems.map((compareItem) => ({ id: `generation-compare-${compareItem.mode}`, section: "Generate", title: compareItem.title, subtitle: generation.compareMode === compareItem.mode ? "Current compare mode" : undefined, disabled: !hasCandidates, icon: , run: () => dispatch(commandIds.generationSetCompareMode, { mode: compareItem.mode }), })), ); items.push( { id: "zoom-in", section: "Zoom", title: "Zoom in", subtitle: `${Math.round(viewport.zoom * 100)}%`, icon: , run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom * 1.2 }), }, { id: "zoom-out", section: "Zoom", title: "Zoom out", subtitle: `${Math.round(viewport.zoom * 100)}%`, icon: , run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom / 1.2 }), }, { id: "zoom-100", section: "Zoom", title: "Zoom to 100%", icon: , run: () => dispatch(commandIds.viewportSetZoom, { zoom: 1 }), }, { id: "fit-artboard", section: "Zoom", title: "Fit artboard", subtitle: activeArtboard?.name, disabled: !activeArtboard, icon: , run: () => dispatch(commandIds.viewportFitArtboard, undefined), }, ); items.push( { id: "debug-reset-viewport", section: "Debug", title: "Reset viewport", icon: , run: () => dispatch(commandIds.viewportReset, undefined), }, { id: "debug-clear-selection", section: "Debug", title: "Clear selection", subtitle: selection.layerIds.length > 0 || selection.artboardId ? undefined : "Nothing selected", disabled: selection.layerIds.length === 0 && !selection.artboardId, icon: , run: () => dispatch(commandIds.selectionClear, undefined), }, { id: "debug-clear-candidates", section: "Debug", title: "Clear generation state", disabled: !hasCandidates, icon: , run: () => dispatch(commandIds.generationClearCandidates, undefined), }, ); return items; } const generateModeItems: Array<{ mode: GenerateMode; title: string }> = [ { mode: "text-to-image", title: "Text-to-image mode" }, { mode: "image-to-image", title: "Image-to-image mode" }, { mode: "inpaint", title: "Inpaint mode" }, { mode: "outpaint", title: "Outpaint mode" }, ]; const generationCompareItems: Array<{ mode: GenerationCompareMode; title: string }> = [ { mode: "result", title: "Show result" }, { mode: "before", title: "Show before" }, { mode: "split", title: "Split compare" }, ]; 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 toolIcon(tool: ToolId) { switch (tool) { case "select": return ; case "generate": return ; case "brush": return ; case "eraser": return ; case "chromaKey": return ; case "magicWand": return ; case "pan": return ; } } 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"; }