feat: implement chroma key operation and related keybinds, update workspace panel management

This commit is contained in:
syntaxbullet
2026-07-11 00:09:41 +02:00
parent 51a54dbdb2
commit 556fd685a2
21 changed files with 134 additions and 50 deletions

View File

@@ -13,6 +13,14 @@ describe("tool commands", () => {
expect(next.editor.tools).toEqual({ activeTool: "brush", interactionMode: { type: "tool", tool: "brush" }, brush: defaultBrush, generate: defaultGenerate, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
});
test("selecting a persistent tool closes an open operation", () => {
const state = createInitialAppState("Test");
state.editor.workspace = { panel: "chromaKey" };
const next = toolSetActiveCommand.execute({ state }, { tool: "brush" });
expect(next.editor.tools.activeTool).toBe("brush");
expect(next.editor.workspace.panel).toBe("none");
});
test("sets brush settings", () => {
const next = toolSetBrushSettingsCommand.execute({ state: createInitialAppState("Test") }, { color: "#ff0000", size: 24, hardness: 50 });

View File

@@ -44,8 +44,6 @@ export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
id: commandIds.toolSetActive,
name: "Set active tool",
execute({ state }, payload) {
const previousNonGenerateTool = payload.tool === "generate" ? state.editor.workspace.previousNonGenerateTool : payload.tool;
const panel = payload.tool === "generate" ? "generate" : state.editor.workspace.panel === "generate" ? "none" : state.editor.workspace.panel;
return {
...state,
editor: {
@@ -57,7 +55,9 @@ export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
},
brushPreview: undefined,
brushStrokePreview: undefined,
workspace: { panel, previousNonGenerateTool },
workspace: state.editor.workspace.panel === "generate" || state.editor.workspace.panel === "chromaKey"
? { panel: "none" }
: state.editor.workspace,
},
};
},

View File

@@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { workspaceSetPanelCommand } from "./workspace";
describe("workspace commands", () => {
test.each(["generate", "chromaKey"] as const)("opens the %s operation without changing the persistent tool", (panel) => {
const state = createInitialAppState("Test");
const next = workspaceSetPanelCommand.execute({ state }, { panel });
expect(next.editor.workspace.panel).toBe(panel);
expect(next.editor.tools.activeTool).toBe("select");
expect(next.editor.tools.interactionMode).toEqual({ type: "tool", tool: "select" });
});
});

View File

@@ -10,23 +10,17 @@ export const workspaceSetPanelCommand: Command<WorkspaceSetPanelPayload> = {
history: { mode: "ignore" },
execute({ state }, payload) {
if (!workspacePanels.has(payload.panel)) return state;
const currentTool = state.editor.tools.activeTool;
const previousNonGenerateTool = currentTool === "generate" ? state.editor.workspace.previousNonGenerateTool : currentTool;
const nextTool = payload.panel === "generate" ? "generate" : currentTool === "generate" ? previousNonGenerateTool : currentTool;
if (state.editor.workspace.panel === payload.panel && currentTool === nextTool) return state;
if (state.editor.workspace.panel === payload.panel) return state;
return {
...state,
editor: {
...state.editor,
workspace: { panel: payload.panel, previousNonGenerateTool },
tools: nextTool === currentTool ? state.editor.tools : { ...state.editor.tools, activeTool: nextTool, interactionMode: { type: "tool", tool: nextTool } },
brushPreview: nextTool === currentTool ? state.editor.brushPreview : undefined,
brushStrokePreview: nextTool === currentTool ? state.editor.brushStrokePreview : undefined,
workspace: { panel: payload.panel },
},
};
},
};
const workspacePanels = new Set<WorkspacePanel>(["none", "generate", "layers"]);
const workspacePanels = new Set<WorkspacePanel>(["none", "generate", "chromaKey", "layers"]);
export const workspaceCommands = [workspaceSetPanelCommand] satisfies Command<unknown>[];

View File

@@ -1,5 +1,5 @@
export type { AppState, EditorState, SelectionState, ViewportState } from "./state";
export type { BrushSettings, ChromaKeySettings, MagicWandSettings, InteractionMode, ToolId, ToolState } from "./tools";
export type { BrushSettings, ChromaKeySettings, MagicWandSettings, InteractionMode, OperationId, ToolId, ToolState } from "./tools";
export { initialToolState } from "./tools";
export { createInitialAppState, initialEditorState } from "./initial-state";
export type { AppStore, StateListener } from "./store";

View File

@@ -26,7 +26,6 @@ export const initialEditorState: EditorState = {
},
workspace: {
panel: "none",
previousNonGenerateTool: "select",
},
transformSession: undefined,
maskEdit: undefined,

View File

@@ -1,7 +1,7 @@
import type { ImageDocument } from "@core/document";
import type { Angle, Rect, Size, Transform, Vec2D } from "@core/geometry";
import type { ArtboardId, AssetId, GenerationCandidateId, GenerationJobId, LayerId } from "@core/id";
import type { GenerateArchitecture, GenerateMode, GenerateSettings, ToolId, ToolState } from "./tools";
import type { GenerateArchitecture, GenerateMode, GenerateSettings, ToolState } from "./tools";
import type { TransformSession } from "./transform";
export type ViewportState = {
@@ -127,11 +127,10 @@ export type CommandPaletteState = {
selectedIndex: number;
};
export type WorkspacePanel = "none" | "generate" | "layers";
export type WorkspacePanel = "none" | "generate" | "chromaKey" | "layers";
export type WorkspaceState = {
panel: WorkspacePanel;
previousNonGenerateTool: ToolId;
};
export type EditorState = {

View File

@@ -1,6 +1,9 @@
export const availableToolIds = ["select", "generate", "brush", "eraser", "chromaKey", "magicWand", "pan"] as const;
export const availableToolIds = ["select", "brush", "eraser", "magicWand", "pan"] as const;
export const availableOperationIds = ["generate", "chromaKey"] as const;
export type ToolId = (typeof availableToolIds)[number];
export type OperationId = (typeof availableOperationIds)[number];
export type InteractionMode =
| { type: "tool"; tool: ToolId }

View File

@@ -17,7 +17,7 @@ describe("command palette input", () => {
expect(dispatched).toEqual([{ commandId: commandIds.commandPaletteOpen, payload: undefined }]);
});
test("ignores unmodified K so the chroma key tool keeps its shortcut", () => {
test("ignores unmodified K so the chroma key operation keeps its shortcut", () => {
const consumed = handleCommandPaletteKey({
event: { key: "k", code: "KeyK", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false },
dispatch: () => undefined as never,

View File

@@ -8,6 +8,7 @@ export { handleKeybind, keybindFromEvent } from "./keyboard";
export { handleCommandPaletteKey } from "./command-palette";
export { handleHistoryKey } from "./history";
export { handleToolKey } from "./tool-keybinds";
export { handleOperationKey } from "./operation-keybinds";
export { findGroup, findLayerInfoInDocument, handleDeleteSelectionKey, resolveLayerDrop } from "./layers-panel";
export type { LayerDropTarget, LayerInfo } from "./layers-panel";
export { handleArtboardSelection } from "./selection";

View File

@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { commandIds } from "@commands/ids";
import type { Dispatch } from "@commands/dispatcher";
import { handleOperationKey } from "./operation-keybinds";
describe("operation keybinds", () => {
test.each([
["g", "generate"],
["k", "chromaKey"],
] as const)("opens the %s operation without selecting a tool", (key, panel) => {
const dispatched: unknown[] = [];
const consumed = handleOperationKey({
event: { key, code: `Key${key.toUpperCase()}`, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false },
dispatch: ((commandId: unknown, payload: unknown) => {
dispatched.push({ commandId, payload });
return undefined;
}) as unknown as Dispatch,
});
expect(consumed).toBe(true);
expect(dispatched).toEqual([{ commandId: commandIds.workspaceSetPanel, payload: { panel } }]);
});
});

View File

@@ -0,0 +1,16 @@
import { commandIds } from "@commands/ids";
import type { Dispatch } from "@commands/dispatcher";
import type { KeybindEvent } from "./keyboard";
const operationKeybinds = {
g: "generate",
k: "chromaKey",
} as const;
export function handleOperationKey(options: { event: KeybindEvent; dispatch: Dispatch }): boolean {
if (options.event.altKey || options.event.ctrlKey || options.event.metaKey) return false;
const panel = operationKeybinds[options.event.key.toLowerCase() as keyof typeof operationKeybinds];
if (!panel) return false;
options.dispatch(commandIds.workspaceSetPanel, { panel });
return true;
}

View File

@@ -3,10 +3,8 @@ import type { Dispatch } from "@commands/dispatcher";
import type { KeybindEvent } from "./keyboard";
const toolKeybinds = {
g: "generate",
b: "brush",
e: "eraser",
k: "chromaKey",
w: "magicWand",
p: "pan",
s: "select",

View File

@@ -16,7 +16,7 @@ import type { PointerInputEvent } from "./pointer";
type TransformHandle = "body" | "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
type InputToolId = "select" | "generate" | "brush" | "eraser" | "chromaKey" | "magicWand" | "pan";
type InputToolId = "select" | "brush" | "eraser" | "magicWand" | "pan";
type InputInteractionMode =
| { type: "tool"; tool: InputToolId }

View File

@@ -28,6 +28,7 @@ export async function applyChromaKeyMask(target: NonNullable<ReturnType<typeof r
dispatch(commandIds.toolSetBrushStrokePreview, undefined);
if (target.maskAsset && target.maskLayer && target.maskLayer.type !== "group") {
dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: target.maskLayer.id, source, mimeType: "image/png", operation: { type: "chromaKey" } });
dispatch(commandIds.workspaceSetPanel, { panel: "none" });
return;
}
const assetId = crypto.randomUUID();
@@ -40,7 +41,7 @@ export async function applyChromaKeyMask(target: NonNullable<ReturnType<typeof r
maskLayer: { id: maskLayerId, type: "raster", name: `${target.layer.name} Chroma Mask`, visible: true, locked: false, opacity: 1, assetId, transform: { position: { x: target.bounds.x, y: target.bounds.y }, scale: { x: target.bounds.w / width, y: target.bounds.h / height }, rotation: target.layer.transform.rotation } },
});
dispatch(commandIds.toolExitMaskEdit, undefined);
dispatch(commandIds.toolSetActive, { tool: "chromaKey" });
dispatch(commandIds.workspaceSetPanel, { panel: "none" });
}
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {

View File

@@ -13,7 +13,7 @@ import { ShortcutsDisplay } from "./ShortcutsDisplay";
import { ToolOverlay } from "./ToolOverlay";
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
import type { AppState } from "@editor/state";
import { handleCommandPaletteKey, handleDeleteSelectionKey, handleHistoryKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
import { handleCommandPaletteKey, handleDeleteSelectionKey, handleHistoryKey, handleOperationKey, handleToolKey, keybindEventFromKeyboardEvent } from "@input/index";
import { shallowEqual, useAppState } from "./useAppState";
import { downloadArtboardPng } from "@operations/export/downloadArtboard";
import { useImageImport } from "./useImageImport";
@@ -34,6 +34,7 @@ export function App({ app }: AppProps) {
const transformTarget = transformSession?.target ?? selectedTransformTarget(document, selection);
const activeArtboard = document.artboards.find((artboard) => artboard.id === selection.artboardId) ?? document.artboards[0];
const generateOpen = workspace.panel === "generate";
const chromaKeyOpen = workspace.panel === "chromaKey";
const layersOpen = workspace.panel === "layers";
useEffect(() => {
@@ -105,6 +106,12 @@ export function App({ app }: AppProps) {
const key = event.key.toLowerCase();
const operationConsumed = handleOperationKey({ event: keybindEvent, dispatch: app.store.dispatch });
if (operationConsumed) {
event.preventDefault();
return;
}
const toolConsumed = handleToolKey({ event: keybindEvent, dispatch: app.store.dispatch });
if (toolConsumed) {
event.preventDefault();
@@ -179,6 +186,7 @@ export function App({ app }: AppProps) {
<ToolOverlay
activeTool={tools.activeTool}
interactionMode={tools.interactionMode}
panel={workspace.panel}
dispatch={app.store.dispatch}
/>
</div>
@@ -200,9 +208,10 @@ export function App({ app }: AppProps) {
document={document}
selection={selection}
viewport={viewport}
visible={tools.activeTool === "generate" || tools.activeTool === "brush" || tools.activeTool === "eraser" || tools.activeTool === "chromaKey" || tools.activeTool === "magicWand" || Boolean(transformBounds) || viewportActivityIsland.visible}
visible={generateOpen || chromaKeyOpen || tools.activeTool === "brush" || tools.activeTool === "eraser" || tools.activeTool === "magicWand" || Boolean(transformBounds) || viewportActivityIsland.visible}
action={viewportActivityIsland.action}
activeTool={tools.activeTool}
operation={generateOpen ? "generate" : chromaKeyOpen ? "chromaKey" : undefined}
brushSettings={tools.brush}
generateSettings={tools.generate}
generation={generation}

View File

@@ -1,7 +1,7 @@
import type { AppStore } from "@editor/store";
import type { ImageDocument } from "@core/document";
import type { GenerationState, MaskViewMode, SelectionState, ViewportState } from "@editor/state";
import type { BrushSettings, ChromaKeySettings, GenerateSettings, MagicWandSettings, ToolId } from "@editor/tools";
import type { BrushSettings, ChromaKeySettings, GenerateSettings, MagicWandSettings, OperationId, ToolId } from "@editor/tools";
import { BrushControls } from "./bottom-controls/BrushControls";
import { ChromaKeyControls } from "./bottom-controls/ChromaKeyControls";
import { MagicWandControls } from "./bottom-controls/MagicWandControls";
@@ -21,6 +21,7 @@ export type BottomControlsIslandProps = {
visible: boolean;
action: BottomControlsAction;
activeTool: ToolId;
operation?: OperationId;
brushSettings: BrushSettings;
generateSettings: GenerateSettings;
generation: GenerationState;
@@ -35,7 +36,7 @@ export type BottomControlsIslandProps = {
generationWorkflow: GenerationWorkflow;
};
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, brushSettings, generateSettings, generation, chromaKeySettings, magicWandSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch, generationWorkflow }: BottomControlsIslandProps) {
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, operation, brushSettings, generateSettings, generation, chromaKeySettings, magicWandSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch, generationWorkflow }: BottomControlsIslandProps) {
const zoomPercent = Math.round(viewport.zoom * 100);
const x = Math.round(viewport.center.x);
const y = Math.round(viewport.center.y);
@@ -47,13 +48,13 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
visible ? "pointer-events-auto translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"
}`}
>
{activeTool === "generate" ? (
{operation === "generate" ? (
<GenerateActionControls settings={generateSettings} generation={generation} dispatch={dispatch} workflow={generationWorkflow} />
) : (activeTool === "brush" || activeTool === "eraser") && brushHint ? (
<BrushHint tool={activeTool} hint={brushHint} />
) : activeTool === "brush" || activeTool === "eraser" ? (
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskViewMode={maskViewMode} dispatch={dispatch} />
) : activeTool === "chromaKey" ? (
) : operation === "chromaKey" ? (
<ChromaKeyControls document={document} selection={selection} settings={chromaKeySettings} dispatch={dispatch} />
) : activeTool === "magicWand" ? (
<MagicWandControls settings={magicWandSettings} dispatch={dispatch} />

View File

@@ -1,17 +1,19 @@
import { Cursor, Eraser, Hand, PaintBrush, DropHalf, MagicWand, Sparkle } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import type { InteractionMode, ToolId } from "@editor/tools";
import { availableToolIds } from "@editor/tools";
import type { InteractionMode, OperationId, ToolId } from "@editor/tools";
import { availableOperationIds, availableToolIds } from "@editor/tools";
import type { WorkspacePanel } from "@editor/state";
import { labelForTool } from "./toolLabels";
export type ToolOverlayProps = {
activeTool: ToolId;
interactionMode: InteractionMode;
panel: WorkspacePanel;
dispatch: AppStore["dispatch"];
};
export function ToolOverlay({ activeTool, interactionMode, dispatch }: ToolOverlayProps) {
export function ToolOverlay({ activeTool, interactionMode, panel, dispatch }: ToolOverlayProps) {
return (
<nav
aria-label="Tools"
@@ -35,20 +37,34 @@ export function ToolOverlay({ activeTool, interactionMode, dispatch }: ToolOverl
</button>
);
})}
<div className="h-px w-8 bg-white/15" aria-hidden="true" />
{availableOperationIds.map((operation) => {
const active = panel === operation;
const Icon = iconForOperation(operation);
return (
<button
key={operation}
type="button"
aria-label={labelForOperation(operation)}
aria-pressed={active}
title={labelForOperation(operation)}
className={buttonClass(active)}
onClick={() => dispatch(commandIds.workspaceSetPanel, { panel: active ? "none" : operation })}
>
<Icon size={24} weight={active ? "fill" : "regular"} />
</button>
);
})}
</nav>
);
}
function iconForTool(tool: ToolId) {
switch (tool) {
case "generate":
return Sparkle;
case "brush":
return PaintBrush;
case "eraser":
return Eraser;
case "chromaKey":
return DropHalf;
case "magicWand":
return MagicWand;
case "pan":
@@ -58,6 +74,14 @@ function iconForTool(tool: ToolId) {
}
}
function iconForOperation(operation: OperationId) {
return operation === "generate" ? Sparkle : DropHalf;
}
function labelForOperation(operation: OperationId) {
return operation === "generate" ? "Generate" : "Chroma key";
}
function isToolHighlighted(tool: ToolId, activeTool: ToolId, interactionMode: InteractionMode) {
if (interactionMode.type === "temporary-pan") return tool === "pan";
return activeTool === tool;

View File

@@ -7,6 +7,6 @@ export function canvasCursorClass(interactionMode: InteractionMode, isPanning: b
if (!canBrush) return "cursor-not-allowed";
return hasBrushPreview ? "cursor-none" : "cursor-crosshair";
}
if (interactionMode.type === "tool" && (interactionMode.tool === "chromaKey" || interactionMode.tool === "magicWand")) return "cursor-crosshair";
if (interactionMode.type === "tool" && interactionMode.tool === "magicWand") return "cursor-crosshair";
return "cursor-default";
}

View File

@@ -62,10 +62,7 @@ export function createPaletteItems(options: {
subtitle: tool === tools.activeTool ? "Current tool" : undefined,
keywords: [tool],
icon: toolIcon(tool),
run: () => {
if (tool === "generate") openGenerate();
else dispatch(commandIds.toolSetActive, { tool });
},
run: () => dispatch(commandIds.toolSetActive, { tool }),
})),
);
@@ -214,11 +211,18 @@ export function createPaletteItems(options: {
id: "open-generate",
section: "Generate",
title: "Open generate panel",
subtitle: tools.activeTool === "generate" ? "Current tool" : undefined,
keywords: ["ai"],
icon: <Sparkle size={20} />,
run: openGenerate,
},
{
id: "open-chroma-key",
section: "Masks",
title: "Open chroma key operation",
keywords: ["key", "mask", "remove background"],
icon: <DropHalf size={20} />,
run: () => dispatch(commandIds.workspaceSetPanel, { panel: "chromaKey" }),
},
...generateModeItems.map((modeItem) => ({
id: `generate-mode-${modeItem.mode}`,
section: "Generate",
@@ -342,14 +346,10 @@ 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":

View File

@@ -2,12 +2,8 @@ import type { ToolId } from "@editor/tools";
export function labelForTool(tool: ToolId): string {
switch (tool) {
case "generate":
return "Generate";
case "brush":
return "Brush";
case "chromaKey":
return "Chroma key";
case "magicWand":
return "Magic wand";
case "eraser":