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,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":