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:
@@ -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<typeof createImageStudioApp>;
|
||||
|
||||
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) {
|
||||
|
||||
@@ -121,6 +121,11 @@ function snapshot(state: AppState): HistorySnapshot {
|
||||
...state.editor,
|
||||
brushPreview: undefined,
|
||||
brushStrokePreview: undefined,
|
||||
commandPalette: {
|
||||
open: false,
|
||||
query: "",
|
||||
selectedIndex: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
40
commands/palette.test.ts
Normal file
40
commands/palette.test.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
121
commands/palette.ts
Normal file
121
commands/palette.ts
Normal file
@@ -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<CommandPaletteOpenPayload> = {
|
||||
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<CommandPaletteSetQueryPayload> = {
|
||||
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<CommandPaletteSetSelectedIndexPayload> = {
|
||||
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<unknown>[];
|
||||
|
||||
function normalizeSelectedIndex(value: number) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,11 @@ export const initialEditorState: EditorState = {
|
||||
selectedCandidateId: undefined,
|
||||
compareMode: "result",
|
||||
},
|
||||
commandPalette: {
|
||||
open: false,
|
||||
query: "",
|
||||
selectedIndex: 0,
|
||||
},
|
||||
transformSession: undefined,
|
||||
maskEdit: undefined,
|
||||
brushPreview: undefined,
|
||||
|
||||
@@ -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;
|
||||
|
||||
28
input/command-palette.test.ts
Normal file
28
input/command-palette.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
12
input/command-palette.ts
Normal file
12
input/command-palette.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
49
view/App.tsx
49
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 (
|
||||
<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,
|
||||
};
|
||||
|
||||
617
view/CommandPalette.tsx
Normal file
617
view/CommandPalette.tsx
Normal file
@@ -0,0 +1,617 @@
|
||||
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<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,
|
||||
}),
|
||||
[
|
||||
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<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 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: <FolderOpen size={20} />,
|
||||
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: <DownloadSimple size={20} />,
|
||||
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: <Stack size={20} weight={layersOpen ? "fill" : "regular"} />,
|
||||
run: layersOpen ? closeLayers : openLayers,
|
||||
},
|
||||
{
|
||||
id: "add-artboard",
|
||||
section: "Layers",
|
||||
title: "Add artboard",
|
||||
icon: <Plus size={20} />,
|
||||
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: <Plus size={20} />,
|
||||
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: <FolderPlus size={20} />,
|
||||
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: <Stack size={20} />,
|
||||
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: <Stack size={20} weight="fill" />,
|
||||
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: <ArrowUp size={20} />,
|
||||
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: <ArrowDown size={20} />,
|
||||
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 ? <Eye size={20} /> : <EyeSlash size={20} />,
|
||||
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 ? <LockOpen size={20} /> : <Lock size={20} />,
|
||||
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: <Trash size={20} />,
|
||||
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: <Sparkle size={20} />,
|
||||
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: <Sparkle size={20} />,
|
||||
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: <Sparkle size={20} />,
|
||||
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: <Trash size={20} />,
|
||||
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: <Sparkle size={20} />,
|
||||
run: () => dispatch(commandIds.generationSetCompareMode, { mode: compareItem.mode }),
|
||||
})),
|
||||
);
|
||||
|
||||
items.push(
|
||||
{
|
||||
id: "zoom-in",
|
||||
section: "Zoom",
|
||||
title: "Zoom in",
|
||||
subtitle: `${Math.round(viewport.zoom * 100)}%`,
|
||||
icon: <Plus size={20} />,
|
||||
run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom * 1.2 }),
|
||||
},
|
||||
{
|
||||
id: "zoom-out",
|
||||
section: "Zoom",
|
||||
title: "Zoom out",
|
||||
subtitle: `${Math.round(viewport.zoom * 100)}%`,
|
||||
icon: <Minus size={20} />,
|
||||
run: () => dispatch(commandIds.viewportSetZoom, { zoom: viewport.zoom / 1.2 }),
|
||||
},
|
||||
{
|
||||
id: "zoom-100",
|
||||
section: "Zoom",
|
||||
title: "Zoom to 100%",
|
||||
icon: <CornersOut size={20} />,
|
||||
run: () => dispatch(commandIds.viewportSetZoom, { zoom: 1 }),
|
||||
},
|
||||
{
|
||||
id: "fit-artboard",
|
||||
section: "Zoom",
|
||||
title: "Fit artboard",
|
||||
subtitle: activeArtboard?.name,
|
||||
disabled: !activeArtboard,
|
||||
icon: <CornersOut size={20} />,
|
||||
run: () => dispatch(commandIds.viewportFitArtboard, undefined),
|
||||
},
|
||||
);
|
||||
|
||||
items.push(
|
||||
{
|
||||
id: "debug-reset-viewport",
|
||||
section: "Debug",
|
||||
title: "Reset viewport",
|
||||
icon: <CornersOut size={20} />,
|
||||
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: <Cursor size={20} />,
|
||||
run: () => dispatch(commandIds.selectionClear, undefined),
|
||||
},
|
||||
{
|
||||
id: "debug-clear-candidates",
|
||||
section: "Debug",
|
||||
title: "Clear generation state",
|
||||
disabled: !hasCandidates,
|
||||
icon: <Trash size={20} />,
|
||||
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 <Cursor size={20} />;
|
||||
case "generate":
|
||||
return <Sparkle size={20} />;
|
||||
case "brush":
|
||||
return <PaintBrush size={20} />;
|
||||
case "eraser":
|
||||
return <Eraser size={20} />;
|
||||
case "chromaKey":
|
||||
return <DropHalf size={20} />;
|
||||
case "magicWand":
|
||||
return <MagicWand size={20} />;
|
||||
case "pan":
|
||||
return <Hand size={20} />;
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
@@ -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({
|
||||
<span className="grid w-12 flex-none place-items-center"><Plus size={24} /></span>
|
||||
<span className="min-w-0 flex-1 text-center">Artboard</span>
|
||||
</button>
|
||||
<button type="button" className={labeledToolbarButtonClass()} aria-label="Add layer" title="Add layer" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addLayer(document, selectedArtboardId, selectedLayer, dispatch)}>
|
||||
<button type="button" className={labeledToolbarButtonClass()} aria-label="Add layer" title="Add layer" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addEmptyLayer(document, selectedArtboardId, selectedLayer, dispatch)}>
|
||||
<span className="grid w-12 flex-none place-items-center"><Plus size={24} /></span>
|
||||
<span className="min-w-0 flex-1 text-center">Layer</span>
|
||||
</button>
|
||||
<button type="button" className={toolbarButtonClass()} aria-label="Add group" title="Add group" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addGroup(selectedArtboardId, dispatch)}>
|
||||
<button type="button" className={toolbarButtonClass()} aria-label="Add group" title="Add group" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addGroupLayer(selectedArtboardId, dispatch)}>
|
||||
<FolderPlus size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="mb-4 grid grid-cols-5 gap-2">
|
||||
<button type="button" className={toolbarButtonClass()} aria-label="Group selected" title="Group selected" disabled={!canGroup} onClick={() => selection.artboardId && groupSelection(selection.artboardId, selection.layerIds, dispatch)}>
|
||||
<button type="button" className={toolbarButtonClass()} aria-label="Group selected" title="Group selected" disabled={!canGroup} onClick={() => selection.artboardId && groupLayers(selection.artboardId, selection.layerIds, dispatch)}>
|
||||
<Stack size={24} />
|
||||
</button>
|
||||
<button type="button" className={toolbarButtonClass()} aria-label="Ungroup" title="Ungroup" disabled={!canUngroup} onClick={() => selectedLayer && dispatch(commandIds.documentUngroupLayer, { groupId: selectedLayer.layer.id })}>
|
||||
@@ -470,46 +471,6 @@ function RenameInput({ value, onChange, onCommit, onCancel }: { value: string; o
|
||||
);
|
||||
}
|
||||
|
||||
function addLayerMask(documentIndex: DocumentReadIndex, layerInfo: IndexedLayerInfo, dispatch: AppStore["dispatch"]) {
|
||||
const layer = layerInfo.layer;
|
||||
if (layer.type === "group") return;
|
||||
|
||||
const asset = documentIndex.assetById.get(layer.assetId);
|
||||
const bounds = resolveIndexedLayerBounds(documentIndex, layer);
|
||||
if (!asset || !bounds) return;
|
||||
|
||||
const assetId = crypto.randomUUID();
|
||||
const maskLayerId = crypto.randomUUID();
|
||||
const width = Math.max(1, Math.round(asset.intrinsicSize.w));
|
||||
const height = Math.max(1, Math.round(asset.intrinsicSize.h));
|
||||
const source = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="white"/></svg>`)}`;
|
||||
|
||||
dispatch(commandIds.documentAddLayerMask, {
|
||||
layerId: layer.id,
|
||||
asset: {
|
||||
id: assetId,
|
||||
name: `${layer.name} Mask`,
|
||||
mimeType: "image/svg+xml",
|
||||
source,
|
||||
intrinsicSize: { w: width, h: height },
|
||||
},
|
||||
maskLayer: {
|
||||
id: maskLayerId,
|
||||
type: "raster",
|
||||
name: `${layer.name} Mask`,
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
assetId,
|
||||
transform: {
|
||||
position: { x: bounds.x, y: bounds.y },
|
||||
scale: { x: bounds.w / width, y: bounds.h / height },
|
||||
rotation: layer.transform.rotation,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function dropLayer(
|
||||
document: ImageDocument,
|
||||
sourceLayerId: string,
|
||||
@@ -527,111 +488,6 @@ function dropLayer(
|
||||
if (command) dispatch(commandIds.documentMoveLayer, command);
|
||||
}
|
||||
|
||||
function deleteSelection(selection: SelectionState, selectedLayer: IndexedLayerInfo | undefined, dispatch: AppStore["dispatch"]) {
|
||||
if (selectedLayer) {
|
||||
dispatch(commandIds.documentRemoveLayer, { layerId: selectedLayer.layer.id });
|
||||
return;
|
||||
}
|
||||
if (selection.artboardId) dispatch(commandIds.documentRemoveArtboard, { id: selection.artboardId });
|
||||
}
|
||||
|
||||
function addArtboard(document: ImageDocument, dispatch: AppStore["dispatch"]) {
|
||||
const index = document.artboards.length + 1;
|
||||
dispatch(commandIds.documentAddArtboard, {
|
||||
id: crypto.randomUUID(),
|
||||
name: `Artboard ${index}`,
|
||||
bounds: { x: (index - 1) * 40, y: (index - 1) * 40, w: 800, h: 600 },
|
||||
});
|
||||
}
|
||||
|
||||
function addLayer(document: ImageDocument, artboardId: ArtboardId, selectedLayer: IndexedLayerInfo | undefined, dispatch: AppStore["dispatch"]) {
|
||||
const artboard = document.artboards.find((candidate) => candidate.id === artboardId);
|
||||
if (!artboard) return;
|
||||
|
||||
const assetId = crypto.randomUUID();
|
||||
const layerId = crypto.randomUUID();
|
||||
const width = Math.max(1, Math.round(artboard.bounds.w));
|
||||
const height = Math.max(1, Math.round(artboard.bounds.h));
|
||||
const source = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"></svg>`)}`;
|
||||
|
||||
dispatch(commandIds.documentAddAsset, {
|
||||
asset: {
|
||||
id: assetId,
|
||||
name: "Empty Layer",
|
||||
mimeType: "image/svg+xml",
|
||||
source,
|
||||
intrinsicSize: { w: width, h: height },
|
||||
},
|
||||
});
|
||||
dispatch(commandIds.documentAddRasterLayer, {
|
||||
artboardId,
|
||||
parentGroupId: selectedLayer?.layer.type === "group" ? selectedLayer.layer.id : undefined,
|
||||
layer: {
|
||||
id: layerId,
|
||||
type: "raster",
|
||||
name: "Layer",
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
assetId,
|
||||
transform: { position: { x: artboard.bounds.x, y: artboard.bounds.y }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||
},
|
||||
});
|
||||
dispatch(commandIds.selectionSet, { artboardId, layerIds: [layerId] });
|
||||
}
|
||||
|
||||
function addGroup(artboardId: ArtboardId, dispatch: AppStore["dispatch"]) {
|
||||
dispatch(commandIds.documentAddGroupLayer, { artboardId, group: createGroup("Group") });
|
||||
}
|
||||
|
||||
function groupSelection(artboardId: ArtboardId, layerIds: string[], dispatch: AppStore["dispatch"]) {
|
||||
dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") });
|
||||
}
|
||||
|
||||
function moveLayer(documentIndex: DocumentReadIndex, info: IndexedLayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) {
|
||||
const siblings = info.siblings;
|
||||
const maskLayerIds = documentIndex.maskLayerIdsByLayerList.get(siblings) ?? emptyLayerIds;
|
||||
const blocks = siblings.flatMap((layer, index) => {
|
||||
if (maskLayerIds.has(layer.id)) return [];
|
||||
|
||||
const layerMask = getLayerMask(layer);
|
||||
const maskIndex = layerMask ? siblings.findIndex((candidate) => candidate.id === layerMask.maskLayerId) : -1;
|
||||
const start = maskIndex >= 0 ? Math.min(maskIndex, index) : index;
|
||||
const end = maskIndex >= 0 ? Math.max(maskIndex, index) : index;
|
||||
return [{ layerId: layer.id, start, end, size: end - start + 1 }];
|
||||
});
|
||||
|
||||
const currentBlockIndex = blocks.findIndex((block) => block.layerId === info.layer.id);
|
||||
const currentBlock = blocks[currentBlockIndex];
|
||||
const targetBlock = blocks[currentBlockIndex + direction];
|
||||
if (!currentBlock || !targetBlock) return;
|
||||
|
||||
const insertionIndex = direction === -1 ? targetBlock.start : targetBlock.end + 1;
|
||||
const removedBeforeInsertion = currentBlock.end < insertionIndex ? currentBlock.size : currentBlock.start < insertionIndex ? insertionIndex - currentBlock.start : 0;
|
||||
|
||||
dispatch(commandIds.documentMoveLayer, {
|
||||
layerId: info.layer.id,
|
||||
toArtboardId: info.artboardId,
|
||||
toParentGroupId: info.parentGroupId,
|
||||
toIndex: insertionIndex - removedBeforeInsertion,
|
||||
});
|
||||
}
|
||||
|
||||
const emptyLayerIds = new Set<string>();
|
||||
|
||||
function createGroup(name: string): Extract<Layer, { type: "group" }> {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
type: "group",
|
||||
name,
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function toolbarButtonClass() {
|
||||
return "inline-flex size-12 items-center justify-center rounded-full text-white/75 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const shortcuts: Shortcut[] = [
|
||||
{ keys: ["P"], label: "Pan" },
|
||||
{ keys: ["Space"], label: "Hold to pan" },
|
||||
{ keys: ["L"], label: "Layers" },
|
||||
{ keys: ["⌘", "K"], label: "Commands" },
|
||||
{ keys: ["⌘", "O"], label: "Open image" },
|
||||
{ keys: ["⌘", "Z"], label: "Undo" },
|
||||
{ keys: ["⇧", "⌘", "Z"], label: "Redo" },
|
||||
|
||||
159
view/layerActions.ts
Normal file
159
view/layerActions.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { commandIds } from "@commands/ids";
|
||||
import type { ImageDocument } from "@core/document";
|
||||
import type { ArtboardId } from "@core/id";
|
||||
import type { Layer } from "@core/layer";
|
||||
import { getLayerMask } from "@core/layer-mask-utils";
|
||||
import type { DocumentReadIndex, IndexedLayerInfo } from "@editor/document-indexes";
|
||||
import { resolveIndexedLayerBounds } from "@editor/document-indexes";
|
||||
import type { SelectionState } from "@editor/state";
|
||||
import type { AppStore } from "@editor/store";
|
||||
|
||||
export function addArtboard(document: ImageDocument, dispatch: AppStore["dispatch"]) {
|
||||
const index = document.artboards.length + 1;
|
||||
dispatch(commandIds.documentAddArtboard, {
|
||||
id: crypto.randomUUID(),
|
||||
name: `Artboard ${index}`,
|
||||
bounds: { x: (index - 1) * 40, y: (index - 1) * 40, w: 800, h: 600 },
|
||||
});
|
||||
}
|
||||
|
||||
export function addEmptyLayer(
|
||||
document: ImageDocument,
|
||||
artboardId: ArtboardId,
|
||||
selectedLayer: IndexedLayerInfo | undefined,
|
||||
dispatch: AppStore["dispatch"],
|
||||
) {
|
||||
const artboard = document.artboards.find((candidate) => candidate.id === artboardId);
|
||||
if (!artboard) return;
|
||||
|
||||
const assetId = crypto.randomUUID();
|
||||
const layerId = crypto.randomUUID();
|
||||
const width = Math.max(1, Math.round(artboard.bounds.w));
|
||||
const height = Math.max(1, Math.round(artboard.bounds.h));
|
||||
const source = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"></svg>`)}`;
|
||||
|
||||
dispatch(commandIds.documentAddAsset, {
|
||||
asset: {
|
||||
id: assetId,
|
||||
name: "Empty Layer",
|
||||
mimeType: "image/svg+xml",
|
||||
source,
|
||||
intrinsicSize: { w: width, h: height },
|
||||
},
|
||||
});
|
||||
dispatch(commandIds.documentAddRasterLayer, {
|
||||
artboardId,
|
||||
parentGroupId: selectedLayer?.layer.type === "group" ? selectedLayer.layer.id : undefined,
|
||||
layer: {
|
||||
id: layerId,
|
||||
type: "raster",
|
||||
name: "Layer",
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
assetId,
|
||||
transform: { position: { x: artboard.bounds.x, y: artboard.bounds.y }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||
},
|
||||
});
|
||||
dispatch(commandIds.selectionSet, { artboardId, layerIds: [layerId] });
|
||||
}
|
||||
|
||||
export function addGroupLayer(artboardId: ArtboardId, dispatch: AppStore["dispatch"]) {
|
||||
dispatch(commandIds.documentAddGroupLayer, { artboardId, group: createGroup("Group") });
|
||||
}
|
||||
|
||||
export function groupLayers(artboardId: ArtboardId, layerIds: string[], dispatch: AppStore["dispatch"]) {
|
||||
dispatch(commandIds.documentGroupLayers, { artboardId, layerIds, group: createGroup("Group") });
|
||||
}
|
||||
|
||||
export function deleteSelection(selection: SelectionState, selectedLayer: IndexedLayerInfo | undefined, dispatch: AppStore["dispatch"]) {
|
||||
if (selectedLayer) {
|
||||
dispatch(commandIds.documentRemoveLayer, { layerId: selectedLayer.layer.id });
|
||||
return;
|
||||
}
|
||||
if (selection.artboardId) dispatch(commandIds.documentRemoveArtboard, { id: selection.artboardId });
|
||||
}
|
||||
|
||||
export function moveLayer(documentIndex: DocumentReadIndex, info: IndexedLayerInfo, direction: -1 | 1, dispatch: AppStore["dispatch"]) {
|
||||
const siblings = info.siblings;
|
||||
const maskLayerIds = documentIndex.maskLayerIdsByLayerList.get(siblings) ?? emptyLayerIds;
|
||||
const blocks = siblings.flatMap((layer, index) => {
|
||||
if (maskLayerIds.has(layer.id)) return [];
|
||||
|
||||
const layerMask = getLayerMask(layer);
|
||||
const maskIndex = layerMask ? siblings.findIndex((candidate) => candidate.id === layerMask.maskLayerId) : -1;
|
||||
const start = maskIndex >= 0 ? Math.min(maskIndex, index) : index;
|
||||
const end = maskIndex >= 0 ? Math.max(maskIndex, index) : index;
|
||||
return [{ layerId: layer.id, start, end, size: end - start + 1 }];
|
||||
});
|
||||
|
||||
const currentBlockIndex = blocks.findIndex((block) => block.layerId === info.layer.id);
|
||||
const currentBlock = blocks[currentBlockIndex];
|
||||
const targetBlock = blocks[currentBlockIndex + direction];
|
||||
if (!currentBlock || !targetBlock) return;
|
||||
|
||||
const insertionIndex = direction === -1 ? targetBlock.start : targetBlock.end + 1;
|
||||
const removedBeforeInsertion = currentBlock.end < insertionIndex ? currentBlock.size : currentBlock.start < insertionIndex ? insertionIndex - currentBlock.start : 0;
|
||||
|
||||
dispatch(commandIds.documentMoveLayer, {
|
||||
layerId: info.layer.id,
|
||||
toArtboardId: info.artboardId,
|
||||
toParentGroupId: info.parentGroupId,
|
||||
toIndex: insertionIndex - removedBeforeInsertion,
|
||||
});
|
||||
}
|
||||
|
||||
export function addLayerMask(documentIndex: DocumentReadIndex, layerInfo: IndexedLayerInfo, dispatch: AppStore["dispatch"]) {
|
||||
const layer = layerInfo.layer;
|
||||
if (layer.type === "group") return;
|
||||
|
||||
const asset = documentIndex.assetById.get(layer.assetId);
|
||||
const bounds = resolveIndexedLayerBounds(documentIndex, layer);
|
||||
if (!asset || !bounds) return;
|
||||
|
||||
const assetId = crypto.randomUUID();
|
||||
const maskLayerId = crypto.randomUUID();
|
||||
const width = Math.max(1, Math.round(asset.intrinsicSize.w));
|
||||
const height = Math.max(1, Math.round(asset.intrinsicSize.h));
|
||||
const source = `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="white"/></svg>`)}`;
|
||||
|
||||
dispatch(commandIds.documentAddLayerMask, {
|
||||
layerId: layer.id,
|
||||
asset: {
|
||||
id: assetId,
|
||||
name: `${layer.name} Mask`,
|
||||
mimeType: "image/svg+xml",
|
||||
source,
|
||||
intrinsicSize: { w: width, h: height },
|
||||
},
|
||||
maskLayer: {
|
||||
id: maskLayerId,
|
||||
type: "raster",
|
||||
name: `${layer.name} Mask`,
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
assetId,
|
||||
transform: {
|
||||
position: { x: bounds.x, y: bounds.y },
|
||||
scale: { x: bounds.w / width, y: bounds.h / height },
|
||||
rotation: layer.transform.rotation,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const emptyLayerIds = new Set<string>();
|
||||
|
||||
function createGroup(name: string): Extract<Layer, { type: "group" }> {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
type: "group",
|
||||
name,
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user