From 556fd685a23e9b0f566061de05ac595c046dd9bd Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sat, 11 Jul 2026 00:09:41 +0200 Subject: [PATCH] feat: implement chroma key operation and related keybinds, update workspace panel management --- commands/tool.test.ts | 8 +++++++ commands/tool.ts | 6 ++--- commands/workspace.test.ts | 13 +++++++++++ commands/workspace.ts | 12 +++------- editor/index.ts | 2 +- editor/initial-state.ts | 1 - editor/state.ts | 5 ++--- editor/tools.ts | 5 ++++- input/command-palette.test.ts | 2 +- input/index.ts | 1 + input/operation-keybinds.test.ts | 22 ++++++++++++++++++ input/operation-keybinds.ts | 16 ++++++++++++++ input/tool-keybinds.ts | 2 -- input/transform-controls.ts | 2 +- operations/masks/chromaKey.ts | 3 ++- view/App.tsx | 13 +++++++++-- view/BottomControlsIsland.tsx | 9 ++++---- view/ToolOverlay.tsx | 38 ++++++++++++++++++++++++++------ view/canvas/cursor.ts | 2 +- view/paletteItems.tsx | 18 +++++++-------- view/toolLabels.ts | 4 ---- 21 files changed, 134 insertions(+), 50 deletions(-) create mode 100644 commands/workspace.test.ts create mode 100644 input/operation-keybinds.test.ts create mode 100644 input/operation-keybinds.ts diff --git a/commands/tool.test.ts b/commands/tool.test.ts index 590e7e0..6804f28 100644 --- a/commands/tool.test.ts +++ b/commands/tool.test.ts @@ -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 }); diff --git a/commands/tool.ts b/commands/tool.ts index 26c7099..7d79966 100644 --- a/commands/tool.ts +++ b/commands/tool.ts @@ -44,8 +44,6 @@ export const toolSetActiveCommand: Command = { 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 = { }, brushPreview: undefined, brushStrokePreview: undefined, - workspace: { panel, previousNonGenerateTool }, + workspace: state.editor.workspace.panel === "generate" || state.editor.workspace.panel === "chromaKey" + ? { panel: "none" } + : state.editor.workspace, }, }; }, diff --git a/commands/workspace.test.ts b/commands/workspace.test.ts new file mode 100644 index 0000000..cd7c3a1 --- /dev/null +++ b/commands/workspace.test.ts @@ -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" }); + }); +}); diff --git a/commands/workspace.ts b/commands/workspace.ts index ed8fc3a..f52c426 100644 --- a/commands/workspace.ts +++ b/commands/workspace.ts @@ -10,23 +10,17 @@ export const workspaceSetPanelCommand: Command = { 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(["none", "generate", "layers"]); +const workspacePanels = new Set(["none", "generate", "chromaKey", "layers"]); export const workspaceCommands = [workspaceSetPanelCommand] satisfies Command[]; diff --git a/editor/index.ts b/editor/index.ts index 4277029..1528769 100644 --- a/editor/index.ts +++ b/editor/index.ts @@ -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"; diff --git a/editor/initial-state.ts b/editor/initial-state.ts index de64e00..ed5104e 100644 --- a/editor/initial-state.ts +++ b/editor/initial-state.ts @@ -26,7 +26,6 @@ export const initialEditorState: EditorState = { }, workspace: { panel: "none", - previousNonGenerateTool: "select", }, transformSession: undefined, maskEdit: undefined, diff --git a/editor/state.ts b/editor/state.ts index 384da51..3134b6d 100644 --- a/editor/state.ts +++ b/editor/state.ts @@ -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 = { diff --git a/editor/tools.ts b/editor/tools.ts index 45aeab4..eef2bd3 100644 --- a/editor/tools.ts +++ b/editor/tools.ts @@ -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 } diff --git a/input/command-palette.test.ts b/input/command-palette.test.ts index b58e1d1..6ecf459 100644 --- a/input/command-palette.test.ts +++ b/input/command-palette.test.ts @@ -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, diff --git a/input/index.ts b/input/index.ts index 3f7004b..45b7692 100644 --- a/input/index.ts +++ b/input/index.ts @@ -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"; diff --git a/input/operation-keybinds.test.ts b/input/operation-keybinds.test.ts new file mode 100644 index 0000000..d246cdd --- /dev/null +++ b/input/operation-keybinds.test.ts @@ -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 } }]); + }); +}); diff --git a/input/operation-keybinds.ts b/input/operation-keybinds.ts new file mode 100644 index 0000000..2ee4411 --- /dev/null +++ b/input/operation-keybinds.ts @@ -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; +} diff --git a/input/tool-keybinds.ts b/input/tool-keybinds.ts index 6314fb7..a6aba6b 100644 --- a/input/tool-keybinds.ts +++ b/input/tool-keybinds.ts @@ -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", diff --git a/input/transform-controls.ts b/input/transform-controls.ts index 3c84d10..f73c7bf 100644 --- a/input/transform-controls.ts +++ b/input/transform-controls.ts @@ -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 } diff --git a/operations/masks/chromaKey.ts b/operations/masks/chromaKey.ts index 77692a2..db2aab4 100644 --- a/operations/masks/chromaKey.ts +++ b/operations/masks/chromaKey.ts @@ -28,6 +28,7 @@ export async function applyChromaKeyMask(target: NonNullable 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) { @@ -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} diff --git a/view/BottomControlsIsland.tsx b/view/BottomControlsIsland.tsx index ab132f9..806e2d0 100644 --- a/view/BottomControlsIsland.tsx +++ b/view/BottomControlsIsland.tsx @@ -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" ? ( ) : (activeTool === "brush" || activeTool === "eraser") && brushHint ? ( ) : activeTool === "brush" || activeTool === "eraser" ? ( - ) : activeTool === "chromaKey" ? ( + ) : operation === "chromaKey" ? ( ) : activeTool === "magicWand" ? ( diff --git a/view/ToolOverlay.tsx b/view/ToolOverlay.tsx index 28f3833..ba2dd83 100644 --- a/view/ToolOverlay.tsx +++ b/view/ToolOverlay.tsx @@ -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 ( ); } 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; diff --git a/view/canvas/cursor.ts b/view/canvas/cursor.ts index de4c6b4..c46f12e 100644 --- a/view/canvas/cursor.ts +++ b/view/canvas/cursor.ts @@ -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"; } diff --git a/view/paletteItems.tsx b/view/paletteItems.tsx index 57e596c..b09d861 100644 --- a/view/paletteItems.tsx +++ b/view/paletteItems.tsx @@ -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: , run: openGenerate, }, + { + id: "open-chroma-key", + section: "Masks", + title: "Open chroma key operation", + keywords: ["key", "mask", "remove background"], + icon: , + 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 ; - case "generate": - return ; case "brush": return ; case "eraser": return ; - case "chromaKey": - return ; case "magicWand": return ; case "pan": diff --git a/view/toolLabels.ts b/view/toolLabels.ts index cc77ff7..5b53653 100644 --- a/view/toolLabels.ts +++ b/view/toolLabels.ts @@ -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":