Files
image-studio/view/CommandPalette.tsx

242 lines
8.5 KiB
TypeScript

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";
import type { DocumentActions } from "@app/document-actions";
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;
documentActions: DocumentActions;
};
export function CommandPalette({
state,
document,
selection,
viewport,
tools,
generation,
layersOpen,
activeArtboard,
dispatch,
openFilePicker,
openGenerate,
openLayers,
closeLayers,
documentActions,
}: CommandPaletteProps) {
const inputRef = useRef<HTMLInputElement | null>(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,
documentActions,
}),
[
activeArtboard,
activeArtboardId,
canGroup,
canUngroup,
closeLayers,
dispatch,
document,
documentIndex,
documentActions,
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<HTMLInputElement>) => {
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 (
<div
className="pointer-events-auto absolute inset-0 z-50 flex items-start justify-center bg-black/20 px-3 pt-[10vh] backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="Command palette"
onMouseDown={(event) => {
if (event.target === event.currentTarget) close();
}}
>
<div className="w-full max-w-2xl overflow-hidden rounded-2xl bg-slate-950/[0.92] text-white shadow-2xl ring-1 ring-white/[0.12]">
<div className="flex h-16 items-center gap-3 border-b border-white/10 px-4">
<MagnifyingGlass size={22} className="shrink-0 text-white/45" />
<input
ref={inputRef}
value={state.query}
onChange={(event) => 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"
/>
<span className="hidden items-center gap-1 rounded-full bg-white/[0.07] px-2 py-1 text-xs font-semibold text-white/45 sm:inline-flex">
<Command size={14} weight="bold" /> K
</span>
</div>
<div className="subtle-scrollbar max-h-[min(32rem,70vh)] overflow-auto p-2">
{filteredItems.length > 0 ? (
filteredItems.map((item, index) => {
const active = index === activeIndex;
return (
<button
key={item.id}
type="button"
className={paletteItemClass(active, Boolean(item.disabled))}
disabled={item.disabled}
onMouseEnter={() => dispatch(commandIds.commandPaletteSetSelectedIndex, { selectedIndex: index })}
onClick={() => runItem(item)}
>
<span className={paletteIconClass(active)}>{item.icon}</span>
<span className="min-w-0 flex-1 text-left">
<span className="block truncate text-sm font-semibold">{item.title}</span>
<span className={paletteSubtitleClass(active)}>{item.subtitle ?? item.section}</span>
</span>
<span className={paletteSectionClass(active)}>
{item.section}
</span>
</button>
);
})
) : (
<div className="grid h-28 place-items-center text-sm text-white/45">No commands</div>
)}
</div>
</div>
</div>
);
}
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";
}