diff --git a/app/app.ts b/app/app.ts index 0e30a8d..b56cc74 100644 --- a/app/app.ts +++ b/app/app.ts @@ -2,6 +2,7 @@ import { documentCommands } from "@commands/document"; import { generationCommands } from "@commands/generation"; import { historyCommands } from "@commands/history"; import { commandIds } from "@commands/ids"; +import { commandPaletteCommands } from "@commands/palette"; import { createCommandRegistry } from "@commands/registry"; import { selectionCommands } from "@commands/selection"; import { toolCommands } from "@commands/tool"; @@ -13,7 +14,7 @@ import { createAppStore } from "@editor/store"; export type ImageStudioApp = ReturnType; export function createImageStudioApp(options?: { documentName?: string; createDefaultArtboard?: boolean }) { - const registry = createCommandRegistry([...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands, ...generationCommands, ...transformCommands, ...historyCommands]); + const registry = createCommandRegistry([...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands, ...generationCommands, ...transformCommands, ...historyCommands, ...commandPaletteCommands]); const store = createAppStore(createInitialAppState(options?.documentName), registry); if (options?.createDefaultArtboard !== false) { diff --git a/commands/dispatcher.ts b/commands/dispatcher.ts index 355d09c..55f6278 100644 --- a/commands/dispatcher.ts +++ b/commands/dispatcher.ts @@ -121,6 +121,11 @@ function snapshot(state: AppState): HistorySnapshot { ...state.editor, brushPreview: undefined, brushStrokePreview: undefined, + commandPalette: { + open: false, + query: "", + selectedIndex: 0, + }, }, }; } diff --git a/commands/ids.ts b/commands/ids.ts index 88f9988..3ea204f 100644 --- a/commands/ids.ts +++ b/commands/ids.ts @@ -55,4 +55,8 @@ export const commandIds = { viewportFitArtboard: "viewport.fitArtboard", historyUndo: "history.undo", historyRedo: "history.redo", + commandPaletteOpen: "commandPalette.open", + commandPaletteClose: "commandPalette.close", + commandPaletteSetQuery: "commandPalette.setQuery", + commandPaletteSetSelectedIndex: "commandPalette.setSelectedIndex", } as const; diff --git a/commands/index.ts b/commands/index.ts index f393026..46311ab 100644 --- a/commands/index.ts +++ b/commands/index.ts @@ -71,6 +71,18 @@ export type { export type { CommandDispatcher, Dispatch } from "./dispatcher"; export type { CommandId, CommandPayloads } from "./payloads"; export { createCommandDispatcher } from "./dispatcher"; +export { + commandPaletteCloseCommand, + commandPaletteCommands, + commandPaletteOpenCommand, + commandPaletteSetQueryCommand, + commandPaletteSetSelectedIndexCommand, +} from "./palette"; +export type { + CommandPaletteOpenPayload, + CommandPaletteSetQueryPayload, + CommandPaletteSetSelectedIndexPayload, +} from "./palette"; export type { CommandRegistry } from "./registry"; export { createCommandRegistry } from "./registry"; export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection"; diff --git a/commands/palette.test.ts b/commands/palette.test.ts new file mode 100644 index 0000000..45c11c5 --- /dev/null +++ b/commands/palette.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { createInitialAppState } from "@editor/initial-state"; +import { + commandPaletteCloseCommand, + commandPaletteOpenCommand, + commandPaletteSetQueryCommand, + commandPaletteSetSelectedIndexCommand, +} from "./palette"; + +describe("command palette commands", () => { + test("opens with a reset query and selection", () => { + const initial = commandPaletteSetQueryCommand.execute({ state: createInitialAppState("Test") }, { query: "brush" }); + const next = commandPaletteOpenCommand.execute({ state: initial }, undefined); + + expect(next.editor.commandPalette).toEqual({ open: true, query: "", selectedIndex: 0 }); + }); + + test("sets query and returns selected item to the first result", () => { + const opened = commandPaletteOpenCommand.execute({ state: createInitialAppState("Test") }, { query: "layer", selectedIndex: 4 }); + const next = commandPaletteSetQueryCommand.execute({ state: opened }, { query: "zoom" }); + + expect(next.editor.commandPalette).toEqual({ open: true, query: "zoom", selectedIndex: 0 }); + }); + + test("clamps selected index to a non-negative integer", () => { + const opened = commandPaletteOpenCommand.execute({ state: createInitialAppState("Test") }, undefined); + const fractional = commandPaletteSetSelectedIndexCommand.execute({ state: opened }, { selectedIndex: 3.8 }); + const negative = commandPaletteSetSelectedIndexCommand.execute({ state: opened }, { selectedIndex: -1 }); + + expect(fractional.editor.commandPalette.selectedIndex).toBe(3); + expect(negative.editor.commandPalette.selectedIndex).toBe(0); + }); + + test("closes and clears transient palette text", () => { + const opened = commandPaletteOpenCommand.execute({ state: createInitialAppState("Test") }, { query: "debug", selectedIndex: 2 }); + const next = commandPaletteCloseCommand.execute({ state: opened }, undefined); + + expect(next.editor.commandPalette).toEqual({ open: false, query: "", selectedIndex: 0 }); + }); +}); diff --git a/commands/palette.ts b/commands/palette.ts new file mode 100644 index 0000000..09072a0 --- /dev/null +++ b/commands/palette.ts @@ -0,0 +1,121 @@ +import type { Command } from "./command"; +import { commandIds } from "./ids"; + +export type CommandPaletteOpenPayload = { + query?: string; + selectedIndex?: number; +} | undefined; + +export type CommandPaletteSetQueryPayload = { + query: string; +}; + +export type CommandPaletteSetSelectedIndexPayload = { + selectedIndex: number; +}; + +export const commandPaletteOpenCommand: Command = { + id: commandIds.commandPaletteOpen, + name: "Open command palette", + history: { mode: "ignore" }, + execute({ state }, payload) { + const commandPalette = { + open: true, + query: payload?.query ?? "", + selectedIndex: normalizeSelectedIndex(payload?.selectedIndex ?? 0), + }; + if ( + state.editor.commandPalette.open === commandPalette.open && + state.editor.commandPalette.query === commandPalette.query && + state.editor.commandPalette.selectedIndex === commandPalette.selectedIndex + ) { + return state; + } + + return { + ...state, + editor: { + ...state.editor, + commandPalette, + }, + }; + }, +}; + +export const commandPaletteCloseCommand: Command = { + id: commandIds.commandPaletteClose, + name: "Close command palette", + history: { mode: "ignore" }, + execute({ state }) { + const commandPalette = { open: false, query: "", selectedIndex: 0 }; + if ( + state.editor.commandPalette.open === commandPalette.open && + state.editor.commandPalette.query === commandPalette.query && + state.editor.commandPalette.selectedIndex === commandPalette.selectedIndex + ) { + return state; + } + + return { + ...state, + editor: { + ...state.editor, + commandPalette, + }, + }; + }, +}; + +export const commandPaletteSetQueryCommand: Command = { + id: commandIds.commandPaletteSetQuery, + name: "Set command palette query", + history: { mode: "ignore" }, + execute({ state }, payload) { + if (state.editor.commandPalette.query === payload.query && state.editor.commandPalette.selectedIndex === 0) return state; + + return { + ...state, + editor: { + ...state.editor, + commandPalette: { + ...state.editor.commandPalette, + query: payload.query, + selectedIndex: 0, + }, + }, + }; + }, +}; + +export const commandPaletteSetSelectedIndexCommand: Command = { + id: commandIds.commandPaletteSetSelectedIndex, + name: "Set command palette selected index", + history: { mode: "ignore" }, + execute({ state }, payload) { + const selectedIndex = normalizeSelectedIndex(payload.selectedIndex); + if (state.editor.commandPalette.selectedIndex === selectedIndex) return state; + + return { + ...state, + editor: { + ...state.editor, + commandPalette: { + ...state.editor.commandPalette, + selectedIndex, + }, + }, + }; + }, +}; + +export const commandPaletteCommands = [ + commandPaletteOpenCommand, + commandPaletteCloseCommand, + commandPaletteSetQueryCommand, + commandPaletteSetSelectedIndexCommand, +] satisfies Command[]; + +function normalizeSelectedIndex(value: number) { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.floor(value)); +} diff --git a/commands/payloads.ts b/commands/payloads.ts index 7a16854..e5ffe6b 100644 --- a/commands/payloads.ts +++ b/commands/payloads.ts @@ -31,6 +31,11 @@ import type { GenerationSelectCandidatePayload, GenerationSetCompareModePayload, } from "./generation"; +import type { + CommandPaletteOpenPayload, + CommandPaletteSetQueryPayload, + CommandPaletteSetSelectedIndexPayload, +} from "./palette"; import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection"; import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool"; import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform"; @@ -99,6 +104,10 @@ export type CommandPayloads = { [commandIds.viewportFitArtboard]: ViewportFitArtboardPayload | undefined; [commandIds.historyUndo]: void; [commandIds.historyRedo]: void; + [commandIds.commandPaletteOpen]: CommandPaletteOpenPayload; + [commandIds.commandPaletteClose]: void; + [commandIds.commandPaletteSetQuery]: CommandPaletteSetQueryPayload; + [commandIds.commandPaletteSetSelectedIndex]: CommandPaletteSetSelectedIndexPayload; }; export type CommandId = keyof CommandPayloads; diff --git a/editor/initial-state.ts b/editor/initial-state.ts index aa6fac4..357de5f 100644 --- a/editor/initial-state.ts +++ b/editor/initial-state.ts @@ -17,6 +17,11 @@ export const initialEditorState: EditorState = { selectedCandidateId: undefined, compareMode: "result", }, + commandPalette: { + open: false, + query: "", + selectedIndex: 0, + }, transformSession: undefined, maskEdit: undefined, brushPreview: undefined, diff --git a/editor/state.ts b/editor/state.ts index 3a3dca6..d384bc1 100644 --- a/editor/state.ts +++ b/editor/state.ts @@ -87,11 +87,18 @@ export type GenerationState = { compareMode: GenerationCompareMode; }; +export type CommandPaletteState = { + open: boolean; + query: string; + selectedIndex: number; +}; + export type EditorState = { viewport: ViewportState; selection: SelectionState; tools: ToolState; generation: GenerationState; + commandPalette: CommandPaletteState; transformSession?: TransformSession; maskEdit?: MaskEditState; brushPreview?: BrushPreviewState; diff --git a/input/command-palette.test.ts b/input/command-palette.test.ts new file mode 100644 index 0000000..b58e1d1 --- /dev/null +++ b/input/command-palette.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { commandIds } from "@commands/ids"; +import { handleCommandPaletteKey } from "./command-palette"; + +describe("command palette input", () => { + test("opens the command palette with the platform shortcut", () => { + const dispatched: unknown[] = []; + const consumed = handleCommandPaletteKey({ + event: { key: "k", code: "KeyK", altKey: false, ctrlKey: false, metaKey: true, shiftKey: false }, + dispatch: (commandId, payload) => { + dispatched.push({ commandId, payload }); + return undefined as never; + }, + }); + + expect(consumed).toBe(true); + expect(dispatched).toEqual([{ commandId: commandIds.commandPaletteOpen, payload: undefined }]); + }); + + test("ignores unmodified K so the chroma key tool keeps its shortcut", () => { + const consumed = handleCommandPaletteKey({ + event: { key: "k", code: "KeyK", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false }, + dispatch: () => undefined as never, + }); + + expect(consumed).toBe(false); + }); +}); diff --git a/input/command-palette.ts b/input/command-palette.ts new file mode 100644 index 0000000..1c189f4 --- /dev/null +++ b/input/command-palette.ts @@ -0,0 +1,12 @@ +import { commandIds } from "@commands/ids"; +import type { Dispatch } from "@commands/dispatcher"; +import type { KeybindEvent } from "./keyboard"; + +export function handleCommandPaletteKey(options: { event: KeybindEvent; dispatch: Dispatch }): boolean { + if (options.event.altKey || options.event.shiftKey) return false; + const modifier = options.event.metaKey || options.event.ctrlKey; + if (!modifier || options.event.key.toLowerCase() !== "k") return false; + + options.dispatch(commandIds.commandPaletteOpen, undefined); + return true; +} diff --git a/input/index.ts b/input/index.ts index c2455b2..3f7004b 100644 --- a/input/index.ts +++ b/input/index.ts @@ -5,6 +5,7 @@ export { } from "./dom"; export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard"; export { handleKeybind, keybindFromEvent } from "./keyboard"; +export { handleCommandPaletteKey } from "./command-palette"; export { handleHistoryKey } from "./history"; export { handleToolKey } from "./tool-keybinds"; export { findGroup, findLayerInfoInDocument, handleDeleteSelectionKey, resolveLayerDrop } from "./layers-panel"; diff --git a/view/App.tsx b/view/App.tsx index 5a1c7b5..b1b23a8 100644 --- a/view/App.tsx +++ b/view/App.tsx @@ -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 (
{imageImport.input} +
+ ); + }) + ) : ( +
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"; +} diff --git a/view/LayersSheet.tsx b/view/LayersSheet.tsx index df97b4c..ef1f47a 100644 --- a/view/LayersSheet.tsx +++ b/view/LayersSheet.tsx @@ -6,11 +6,12 @@ import type { ImageDocument } from "@core/document"; import type { Layer } from "@core/layer"; import { getLayerMask } from "@core/layer-mask-utils"; import type { ArtboardId } from "@core/id"; -import { createDocumentReadIndex, resolveIndexedLayerBounds, type DocumentReadIndex, type IndexedLayerInfo } from "@editor/document-indexes"; +import { createDocumentReadIndex, type DocumentReadIndex } from "@editor/document-indexes"; import type { MaskEditState, SelectionState } from "@editor/state"; import type { AppStore } from "@editor/store"; import { resolveLayerDrop } from "@input/index"; import { downloadArtboardPng } from "./exportArtboardPng"; +import { addArtboard, addEmptyLayer, addGroupLayer, addLayerMask, deleteSelection, groupLayers, moveLayer } from "./layerActions"; import { analyzeMaskSource, applyMaskRasterOperation, type MaskAnalysis, type MaskRasterOperation } from "./mask/maskRaster"; export type LayersSheetProps = { @@ -76,17 +77,17 @@ function LayersSheetBody({ Artboard - -
-