feat: add command palette functionality and shortcuts

- Implemented command palette with keyboard shortcut (⌘K) for opening.
- Added commands for opening, closing, setting query, and selecting items in the command palette.
- Created tests for command palette commands and input handling.
- Enhanced layer actions with functions for adding, grouping, and deleting layers.
- Updated UI components to integrate command palette and shortcuts.
This commit is contained in:
syntaxbullet
2026-07-05 17:15:20 +02:00
parent 5eaf37ba28
commit c3a75cf0d6
17 changed files with 1073 additions and 154 deletions

View File

@@ -5,6 +5,7 @@ import { commandIds } from "@commands/ids";
import { BottomControlsIsland } from "./BottomControlsIsland";
import { brushUnavailableHint } from "./canvas/brush";
import { CanvasViewport } from "./CanvasViewport";
import { CommandPalette } from "./CommandPalette";
import { GenerateSheet } from "./GenerateSheet";
import { LayersSheet } from "./LayersSheet";
import { ShortcutsDisplay } from "./ShortcutsDisplay";
@@ -12,7 +13,7 @@ import { ToolOverlay } from "./ToolOverlay";
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
import type { AppState } from "@editor/state";
import type { ToolId } from "@editor/tools";
import { handleDeleteSelectionKey, handleHistoryKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
import { handleCommandPaletteKey, handleDeleteSelectionKey, handleHistoryKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
import { shallowEqual, useAppState } from "./useAppState";
import { downloadArtboardPng } from "./exportArtboardPng";
import { useImageImport } from "./useImageImport";
@@ -25,7 +26,7 @@ export type AppProps = {
export function App({ app }: AppProps) {
const shellState = useAppState(app.store, selectAppShellState, shallowEqual);
const { document, selection, viewport, tools, generation, transformSession, maskEdit } = shellState;
const { document, selection, viewport, tools, generation, commandPalette, transformSession, maskEdit } = shellState;
const viewportActivityIsland = useViewportActivityIsland(viewport);
const imageImport = useImageImport(app.store);
const [layersOpen, setLayersOpen] = useState(false);
@@ -65,6 +66,15 @@ export function App({ app }: AppProps) {
else openGenerate();
}, [closeGenerate, generateOpen, openGenerate]);
const openLayers = useCallback(() => {
closeGenerate();
setLayersOpen(true);
}, [closeGenerate]);
const closeLayers = useCallback(() => {
setLayersOpen(false);
}, []);
const toggleLayers = useCallback(() => {
const nextOpen = !layersOpen;
if (nextOpen) closeGenerate();
@@ -73,6 +83,21 @@ export function App({ app }: AppProps) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const keybindEvent = keybindEventFromKeyboardEvent(event);
const paletteConsumed = handleCommandPaletteKey({ event: keybindEvent, dispatch: app.store.dispatch });
if (paletteConsumed) {
event.preventDefault();
return;
}
if (app.store.getState().editor.commandPalette.open) {
if (event.key === "Escape") {
app.store.dispatch(commandIds.commandPaletteClose, undefined);
event.preventDefault();
}
return;
}
const target = event.target;
const editableTarget =
target instanceof HTMLElement &&
@@ -80,7 +105,7 @@ export function App({ app }: AppProps) {
if (editableTarget) return;
const historyConsumed = handleHistoryKey({ event: keybindEventFromKeyboardEvent(event), dispatch: app.store.dispatch });
const historyConsumed = handleHistoryKey({ event: keybindEvent, dispatch: app.store.dispatch });
if (historyConsumed) {
event.preventDefault();
return;
@@ -95,7 +120,6 @@ export function App({ app }: AppProps) {
}
const key = event.key.toLowerCase();
const keybindEvent = keybindEventFromKeyboardEvent(event);
const toolConsumed = handleToolKey({ event: keybindEvent, dispatch: app.store.dispatch });
if (toolConsumed) {
@@ -126,6 +150,21 @@ export function App({ app }: AppProps) {
return (
<main className="relative h-full overflow-hidden bg-[radial-gradient(circle_at_20%_18%,rgba(148,163,184,0.18),transparent_34%),radial-gradient(circle_at_82%_22%,rgba(71,85,105,0.22),transparent_36%),radial-gradient(circle_at_48%_88%,rgba(30,41,59,0.28),transparent_40%),linear-gradient(135deg,#020617_0%,#0f172a_46%,#111827_100%)] text-foreground">
{imageImport.input}
<CommandPalette
state={commandPalette}
document={document}
selection={selection}
viewport={viewport}
tools={tools}
generation={generation}
layersOpen={layersOpen}
activeArtboard={activeArtboard}
dispatch={app.store.dispatch}
openFilePicker={imageImport.openFilePicker}
openGenerate={openGenerate}
openLayers={openLayers}
closeLayers={closeLayers}
/>
<header className="pointer-events-none absolute inset-x-3 top-3 z-10 flex h-20 items-center justify-end gap-4 rounded-full px-4 text-white backdrop-blur-xl">
<div className="pointer-events-auto flex items-center gap-2">
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker}>
@@ -201,6 +240,7 @@ type AppShellState = {
viewport: AppState["editor"]["viewport"];
tools: AppState["editor"]["tools"];
generation: AppState["editor"]["generation"];
commandPalette: AppState["editor"]["commandPalette"];
transformSession: AppState["editor"]["transformSession"];
maskEdit: AppState["editor"]["maskEdit"];
};
@@ -212,6 +252,7 @@ function selectAppShellState(state: AppState): AppShellState {
viewport: state.editor.viewport,
tools: state.editor.tools,
generation: state.editor.generation,
commandPalette: state.editor.commandPalette,
transformSession: state.editor.transformSession,
maskEdit: state.editor.maskEdit,
};