diff --git a/README.md b/README.md index 99123b0..b5bec98 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,92 @@ # Image Studio -Minimal Bun + React starter with Tailwind CSS and shadcn/ui configured. +Image Studio is a Bun + React image editor prototype. It uses a command-driven architecture for document and editor state, a canvas/WebGL renderer layer, and a lightweight React UI for tools, layers, imports, exports, and shortcuts. -## Install +## Features + +- Import images into an artboard +- Export the active artboard as PNG +- Layer selection and layer sheet +- Select, transform, pan, brush, eraser, chroma key, and magic wand tools +- Mask-edit workflow support +- Undo/redo through command history +- Keyboard shortcuts and pointer/wheel canvas input + +## Tech stack + +- [Bun](https://bun.sh/) for runtime, package management, tests, and builds +- React 19 +- Tailwind CSS 4 +- shadcn/ui-style component utilities +- TypeScript + +## Getting started + +Install dependencies: ```bash bun install ``` -## Development +Start the development server: ```bash bun dev ``` -## Production +Build for production: ```bash bun run build +``` + +Run the production server: + +```bash bun start ``` + +## Scripts + +| Command | Description | +| --- | --- | +| `bun dev` | Start the app with Bun hot reload | +| `bun run build` | Build the production bundle | +| `bun start` | Run the production server | +| `bun test` | Run tests | +| `bun run lint` | Run ESLint | + +## Keyboard shortcuts + +| Shortcut | Action | +| --- | --- | +| `S` | Select tool | +| `B` | Brush tool | +| `E` | Eraser tool | +| `K` | Chroma key tool | +| `W` | Magic wand tool | +| `Shift` + click | Add to magic wand selection | +| `Alt` + click | Subtract from magic wand selection | +| `P` | Pan tool | +| `Space` | Hold to pan | +| `L` | Toggle layers | +| `Cmd/Ctrl` + `O` | Open image | +| `Cmd/Ctrl` + `Z` | Undo | +| `Shift` + `Cmd/Ctrl` + `Z` | Redo | +| `Delete` / `Backspace` | Delete selection | + +## Project structure + +```text +app/ Composition root for app wiring +commands/ Deterministic state changes and command history +core/ Pure document/domain models +editor/ Transient editor state, tools, viewport, selection, store +input/ Keyboard, pointer, wheel, and canvas input resolution +renderer/ Canvas/WebGL rendering and overlays +view/ React UI shell and controls +``` + +## Architecture notes + +State changes flow through commands. React components present state and dispatch user intent, while document/editor mutations are handled by command modules. The renderer consumes document and editor snapshots to draw the canvas and overlays without owning application state. diff --git a/commands/ids.ts b/commands/ids.ts index f917e8c..a7cb1c0 100644 --- a/commands/ids.ts +++ b/commands/ids.ts @@ -26,6 +26,7 @@ export const commandIds = { toolSetActive: "tool.setActive", toolSetBrushSettings: "tool.setBrushSettings", toolSetChromaKeySettings: "tool.setChromaKeySettings", + toolSetMagicWandSettings: "tool.setMagicWandSettings", toolSetBrushPreview: "tool.setBrushPreview", toolSetBrushStrokePreview: "tool.setBrushStrokePreview", toolSetMaskViewMode: "tool.setMaskViewMode", diff --git a/commands/index.ts b/commands/index.ts index 71ef440..2ddfd1c 100644 --- a/commands/index.ts +++ b/commands/index.ts @@ -54,10 +54,10 @@ export type { CommandRegistry } from "./registry"; export { createCommandRegistry } from "./registry"; export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection"; export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection"; -export { toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetMaskViewModeCommand } from "./tool"; +export { toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetMagicWandSettingsCommand, toolSetMaskViewModeCommand } from "./tool"; export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform"; export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform"; -export type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetMaskViewModePayload } from "./tool"; +export type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool"; export { viewportCommands, viewportPanCommand, diff --git a/commands/payloads.ts b/commands/payloads.ts index 0f9eaff..5295cde 100644 --- a/commands/payloads.ts +++ b/commands/payloads.ts @@ -23,7 +23,7 @@ import type { DocumentUngroupLayerPayload, } from "./document"; import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection"; -import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetMaskViewModePayload } from "./tool"; +import type { ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool"; import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform"; import type { ViewportFitArtboardPayload, @@ -61,6 +61,7 @@ export type CommandPayloads = { [commandIds.toolSetActive]: ToolSetActivePayload; [commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload; [commandIds.toolSetChromaKeySettings]: ToolSetChromaKeySettingsPayload; + [commandIds.toolSetMagicWandSettings]: ToolSetMagicWandSettingsPayload; [commandIds.toolSetBrushPreview]: ToolSetBrushPreviewPayload; [commandIds.toolSetBrushStrokePreview]: ToolSetBrushStrokePreviewPayload; [commandIds.toolSetMaskViewMode]: ToolSetMaskViewModePayload; diff --git a/commands/tool.test.ts b/commands/tool.test.ts index 0dfbc05..2d99138 100644 --- a/commands/tool.test.ts +++ b/commands/tool.test.ts @@ -4,11 +4,12 @@ import { toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEdi const defaultBrush = { color: "#111827", size: 8, hardness: 100 }; const defaultChromaKey = { color: "#00ff00", tolerance: 32, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 }; +const defaultMagicWand = { tolerance: 32, feather: 0, choke: 0, despeckle: 0, contiguous: true, mode: "replace" as const }; describe("tool commands", () => { test("sets active tool", () => { const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "brush" }); - expect(next.editor.tools).toEqual({ activeTool: "brush", interactionMode: { type: "tool", tool: "brush" }, brush: defaultBrush, chromaKey: defaultChromaKey }); + expect(next.editor.tools).toEqual({ activeTool: "brush", interactionMode: { type: "tool", tool: "brush" }, brush: defaultBrush, chromaKey: defaultChromaKey, magicWand: defaultMagicWand }); }); test("sets brush settings", () => { @@ -63,7 +64,7 @@ describe("tool commands", () => { const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined); const restored = toolExitTemporaryPanCommand.execute({ state: panning }, undefined); - expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: defaultBrush, chromaKey: defaultChromaKey }); + expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: defaultBrush, chromaKey: defaultChromaKey, magicWand: defaultMagicWand }); expect(restored.editor.tools).toEqual(initial.editor.tools); }); }); diff --git a/commands/tool.ts b/commands/tool.ts index d0d9e39..24ba357 100644 --- a/commands/tool.ts +++ b/commands/tool.ts @@ -3,7 +3,7 @@ import type { Vec2D } from "@core/geometry"; import type { LayerId, ArtboardId, AssetId } from "@core/id"; import type { Layer } from "@core/layer"; import type { MaskViewMode } from "@editor/state"; -import type { BrushSettings, ChromaKeySettings, ToolId } from "@editor/tools"; +import type { BrushSettings, ChromaKeySettings, MagicWandSettings, ToolId } from "@editor/tools"; import type { Command } from "./command"; import { commandIds } from "./ids"; @@ -15,6 +15,8 @@ export type ToolSetBrushSettingsPayload = Partial; export type ToolSetChromaKeySettingsPayload = Partial; +export type ToolSetMagicWandSettingsPayload = Partial; + export type ToolSetBrushPreviewPayload = { position: Vec2D } | undefined; export type ToolSetBrushStrokePreviewPayload = @@ -100,6 +102,30 @@ export const toolSetChromaKeySettingsCommand: Command = { + id: commandIds.toolSetMagicWandSettings, + name: "Set magic wand settings", + execute({ state }, payload) { + return { + ...state, + editor: { + ...state.editor, + tools: { + ...state.editor.tools, + magicWand: { + tolerance: clampNumber(payload.tolerance ?? state.editor.tools.magicWand.tolerance, 0, 255), + feather: clampNumber(payload.feather ?? state.editor.tools.magicWand.feather, 0, 20), + choke: clampNumber(payload.choke ?? state.editor.tools.magicWand.choke, -20, 20), + despeckle: clampNumber(payload.despeckle ?? state.editor.tools.magicWand.despeckle, 0, 20), + contiguous: payload.contiguous ?? state.editor.tools.magicWand.contiguous, + mode: payload.mode ?? state.editor.tools.magicWand.mode, + }, + }, + }, + }; + }, +}; + export const toolSetBrushPreviewCommand: Command = { id: commandIds.toolSetBrushPreview, name: "Set brush preview", @@ -250,6 +276,7 @@ export const toolCommands = [ toolSetActiveCommand, toolSetBrushSettingsCommand, toolSetChromaKeySettingsCommand, + toolSetMagicWandSettingsCommand, toolSetBrushPreviewCommand, toolSetBrushStrokePreviewCommand, toolSetMaskViewModeCommand, diff --git a/editor/index.ts b/editor/index.ts index a9c8e3d..0f2d203 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, InteractionMode, ToolId, ToolState } from "./tools"; +export type { BrushSettings, ChromaKeySettings, MagicWandSettings, InteractionMode, ToolId, ToolState } from "./tools"; export { initialToolState } from "./tools"; export { createInitialAppState, initialEditorState } from "./initial-state"; export type { AppStore, StateListener } from "./store"; diff --git a/editor/tools.ts b/editor/tools.ts index 3302ff3..565e771 100644 --- a/editor/tools.ts +++ b/editor/tools.ts @@ -1,4 +1,4 @@ -export const availableToolIds = ["select", "brush", "eraser", "chromaKey", "pan"] as const; +export const availableToolIds = ["select", "brush", "eraser", "chromaKey", "magicWand", "pan"] as const; export type ToolId = (typeof availableToolIds)[number]; @@ -22,11 +22,23 @@ export type ChromaKeySettings = { spill: number; }; +export type MagicWandMode = "replace" | "add" | "subtract"; + +export type MagicWandSettings = { + tolerance: number; + feather: number; + choke: number; + despeckle: number; + contiguous: boolean; + mode: MagicWandMode; +}; + export type ToolState = { activeTool: ToolId; interactionMode: InteractionMode; brush: BrushSettings; chromaKey: ChromaKeySettings; + magicWand: MagicWandSettings; }; export const initialToolState: ToolState = { @@ -34,6 +46,7 @@ export const initialToolState: ToolState = { interactionMode: { type: "tool", tool: "select" }, brush: { color: "#111827", size: 8, hardness: 100 }, chromaKey: { color: "#00ff00", tolerance: 32, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 }, + magicWand: { tolerance: 32, feather: 0, choke: 0, despeckle: 0, contiguous: true, mode: "replace" }, }; export function isPanInteractionMode(interactionMode: InteractionMode): boolean { diff --git a/input/tool-keybinds.ts b/input/tool-keybinds.ts index dfdcdd4..ced12f3 100644 --- a/input/tool-keybinds.ts +++ b/input/tool-keybinds.ts @@ -6,6 +6,7 @@ const toolKeybinds = { b: "brush", e: "eraser", k: "chromaKey", + w: "magicWand", p: "pan", s: "select", } as const; diff --git a/input/transform-controls.ts b/input/transform-controls.ts index 444e53d..4c985a4 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" | "brush" | "eraser" | "chromaKey" | "pan"; +type InputToolId = "select" | "brush" | "eraser" | "chromaKey" | "magicWand" | "pan"; type InputInteractionMode = | { type: "tool"; tool: InputToolId } diff --git a/view/App.tsx b/view/App.tsx index e14af8b..ff08ede 100644 --- a/view/App.tsx +++ b/view/App.tsx @@ -115,11 +115,12 @@ export function App({ app }: AppProps) { document={state.document} selection={state.editor.selection} viewport={state.editor.viewport} - visible={state.editor.tools.activeTool === "brush" || state.editor.tools.activeTool === "eraser" || state.editor.tools.activeTool === "chromaKey" || Boolean(transformBounds) || viewportActivityIsland.visible} + visible={state.editor.tools.activeTool === "brush" || state.editor.tools.activeTool === "eraser" || state.editor.tools.activeTool === "chromaKey" || state.editor.tools.activeTool === "magicWand" || Boolean(transformBounds) || viewportActivityIsland.visible} action={viewportActivityIsland.action} activeTool={state.editor.tools.activeTool} brushSettings={state.editor.tools.brush} chromaKeySettings={state.editor.tools.chromaKey} + magicWandSettings={state.editor.tools.magicWand} editingMask={Boolean(state.editor.maskEdit)} maskViewMode={state.editor.maskEdit?.viewMode ?? "composite"} brushHint={brushHint} diff --git a/view/BottomControlsIsland.tsx b/view/BottomControlsIsland.tsx index 8e6a5cd..84c75a3 100644 --- a/view/BottomControlsIsland.tsx +++ b/view/BottomControlsIsland.tsx @@ -1,9 +1,10 @@ import type { AppStore } from "@editor/store"; import type { ImageDocument } from "@core/document"; import type { MaskViewMode, SelectionState, ViewportState } from "@editor/state"; -import type { BrushSettings, ChromaKeySettings, ToolId } from "@editor/tools"; +import type { BrushSettings, ChromaKeySettings, MagicWandSettings, ToolId } from "@editor/tools"; import { BrushControls } from "./bottom-controls/BrushControls"; import { ChromaKeyControls } from "./bottom-controls/ChromaKeyControls"; +import { MagicWandControls } from "./bottom-controls/MagicWandControls"; import { PanControls } from "./bottom-controls/PanControls"; import { TransformControls } from "./bottom-controls/TransformControls"; import { ZoomControls } from "./bottom-controls/ZoomControls"; @@ -20,6 +21,7 @@ export type BottomControlsIslandProps = { activeTool: ToolId; brushSettings: BrushSettings; chromaKeySettings: ChromaKeySettings; + magicWandSettings: MagicWandSettings; editingMask?: boolean; maskViewMode?: MaskViewMode; transformBounds?: Rect; @@ -28,7 +30,7 @@ export type BottomControlsIslandProps = { dispatch: AppStore["dispatch"]; }; -export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, brushSettings, chromaKeySettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch }: BottomControlsIslandProps) { +export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, brushSettings, chromaKeySettings, magicWandSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch }: BottomControlsIslandProps) { const zoomPercent = Math.round(viewport.zoom * 100); const x = Math.round(viewport.center.x); const y = Math.round(viewport.center.y); @@ -46,6 +48,8 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a ) : activeTool === "chromaKey" ? ( + ) : activeTool === "magicWand" ? ( + ) : transformBounds && transformTarget ? ( ) : action === "pan" ? ( diff --git a/view/ShortcutsDisplay.tsx b/view/ShortcutsDisplay.tsx index 588e200..7854063 100644 --- a/view/ShortcutsDisplay.tsx +++ b/view/ShortcutsDisplay.tsx @@ -11,6 +11,9 @@ const shortcuts: Shortcut[] = [ { keys: ["B"], label: "Brush" }, { keys: ["E"], label: "Eraser" }, { keys: ["K"], label: "Chroma key" }, + { keys: ["W"], label: "Magic wand" }, + { keys: ["⇧", "Click"], label: "Wand add" }, + { keys: ["Alt", "Click"], label: "Wand subtract" }, { keys: ["P"], label: "Pan" }, { keys: ["Space"], label: "Hold to pan" }, { keys: ["L"], label: "Layers" }, diff --git a/view/ToolOverlay.tsx b/view/ToolOverlay.tsx index e482040..307cee0 100644 --- a/view/ToolOverlay.tsx +++ b/view/ToolOverlay.tsx @@ -1,4 +1,4 @@ -import { Cursor, Eraser, Hand, PaintBrush, DropHalf } from "@phosphor-icons/react"; +import { Cursor, Eraser, Hand, PaintBrush, DropHalf, MagicWand } from "@phosphor-icons/react"; import { commandIds } from "@commands/ids"; import type { AppStore } from "@editor/store"; import type { InteractionMode, ToolId } from "@editor/tools"; @@ -47,6 +47,8 @@ function iconForTool(tool: ToolId) { return Eraser; case "chromaKey": return DropHalf; + case "magicWand": + return MagicWand; case "pan": return Hand; case "select": diff --git a/view/bottom-controls/MagicWandControls.tsx b/view/bottom-controls/MagicWandControls.tsx new file mode 100644 index 0000000..e12ff08 --- /dev/null +++ b/view/bottom-controls/MagicWandControls.tsx @@ -0,0 +1,34 @@ +import { MagicWand } from "@phosphor-icons/react"; +import { commandIds } from "@commands/ids"; +import type { AppStore } from "@editor/store"; +import type { MagicWandSettings } from "@editor/tools"; +import { BottomControlDivider } from "./Divider"; +import { BottomControlSlider } from "./Slider"; +import { bottomControlFieldClass, bottomControlIconSlotClass, bottomControlLabelClass, bottomControlMenuClass } from "./styles"; + +export function MagicWandControls({ settings, dispatch }: { settings: MagicWandSettings; dispatch: AppStore["dispatch"] }) { + return ( +
+ + + dispatch(commandIds.toolSetMagicWandSettings, { tolerance })} /> + + dispatch(commandIds.toolSetMagicWandSettings, { feather })} /> + + dispatch(commandIds.toolSetMagicWandSettings, { choke })} /> + + dispatch(commandIds.toolSetMagicWandSettings, { despeckle })} /> + + + + {(["replace", "add", "subtract"] as const).map((mode) => ( + + ))} + Shift-click adds, Alt-click subtracts +
+ ); +} + +function Slider({ label, value, min, max, onChange }: { label: string; value: number; min: number; max: number; onChange: (value: number) => void }) { + return ; +} diff --git a/view/canvas/cursor.ts b/view/canvas/cursor.ts index 202c94b..c20b9e2 100644 --- a/view/canvas/cursor.ts +++ b/view/canvas/cursor.ts @@ -9,6 +9,6 @@ export function canvasCursorClass(interactionMode: InteractionMode, input: Canva if (!canBrush) return "cursor-not-allowed"; return hasBrushPreview ? "cursor-none" : "cursor-crosshair"; } - if (interactionMode.type === "tool" && interactionMode.tool === "chromaKey") return "cursor-crosshair"; + if (interactionMode.type === "tool" && (interactionMode.tool === "chromaKey" || interactionMode.tool === "magicWand")) return "cursor-crosshair"; return "cursor-default"; } diff --git a/view/canvas/magic-wand.ts b/view/canvas/magic-wand.ts new file mode 100644 index 0000000..b9fbb72 --- /dev/null +++ b/view/canvas/magic-wand.ts @@ -0,0 +1,197 @@ +import { commandIds } from "@commands/ids"; +import type { ImageDocument } from "@core/document"; +import type { Vec2D } from "@core/geometry"; +import type { Layer } from "@core/layer"; +import { resolveTransformTargetBounds } from "@editor/transform-targets"; +import type { AppStore } from "@editor/store"; +import type { EditorState } from "@editor/state"; + +export async function applyMagicWandAt(store: AppStore, point: Vec2D, modeOverride?: EditorState["tools"]["magicWand"]["mode"]) { + const state = store.getState(); + if (state.editor.tools.activeTool !== "magicWand") return false; + const target = resolveTarget(state.document, state.editor); + if (!target) return true; + const x = Math.floor((point.x - target.layer.transform.position.x) / Math.max(0.0001, target.layer.transform.scale.x)); + const y = Math.floor((point.y - target.layer.transform.position.y) / Math.max(0.0001, target.layer.transform.scale.y)); + if (x < 0 || y < 0 || x >= target.asset.intrinsicSize.w || y >= target.asset.intrinsicSize.h) return true; + const source = await createWandMask(target.asset.source, target.maskAsset?.source, Math.round(target.asset.intrinsicSize.w), Math.round(target.asset.intrinsicSize.h), x, y, { ...state.editor.tools.magicWand, mode: modeOverride ?? state.editor.tools.magicWand.mode }); + if (target.maskAsset) { + store.dispatch(commandIds.documentUpdateAssetSource, { assetId: target.maskAsset.id, source }); + return true; + } + const assetId = crypto.randomUUID(); + const maskLayerId = crypto.randomUUID(); + const width = Math.max(1, Math.round(target.asset.intrinsicSize.w)); + const height = Math.max(1, Math.round(target.asset.intrinsicSize.h)); + store.dispatch(commandIds.documentAddLayerMask, { + layerId: target.layer.id, + asset: { id: assetId, name: `${target.layer.name} Wand Mask`, mimeType: "image/png", source, intrinsicSize: { w: width, h: height } }, + maskLayer: { id: maskLayerId, type: "raster", name: `${target.layer.name} Wand 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 } }, + }); + store.dispatch(commandIds.toolExitMaskEdit, undefined); + store.dispatch(commandIds.toolSetActive, { tool: "magicWand" }); + return true; +} + +function resolveTarget(document: ImageDocument, editor: EditorState) { + const layerId = editor.selection.layerIds[0]; + if (!layerId || editor.selection.layerIds.length !== 1) return undefined; + const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId); + if (!layer || layer.type === "group") return undefined; + const asset = document.assets.find((candidate) => candidate.id === layer.assetId); + const bounds = resolveTransformTargetBounds(document, { type: "layer", id: layer.id }); + const maskLayer = layer.clippingMask ? findLayer(document.artboards.flatMap((artboard) => artboard.layers), layer.clippingMask.maskLayerId) : undefined; + const maskAsset = maskLayer && maskLayer.type !== "group" ? document.assets.find((candidate) => candidate.id === maskLayer.assetId) : undefined; + return asset && bounds ? { layer, asset, bounds, maskAsset } : undefined; +} + +async function createWandMask(source: string, existingMaskSource: string | undefined, width: number, height: number, startX: number, startY: number, settings: EditorState["tools"]["magicWand"]) { + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, width); + canvas.height = Math.max(1, height); + const context = canvas.getContext("2d"); + if (!context) return source; + const image = await loadImage(source); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + const imageData = context.getImageData(0, 0, canvas.width, canvas.height); + const start = (startY * canvas.width + startX) * 4; + const key = [imageData.data[start] ?? 0, imageData.data[start + 1] ?? 0, imageData.data[start + 2] ?? 0]; + const selected = postProcessSelection(settings.contiguous ? floodSelect(imageData, canvas.width, canvas.height, startX, startY, key, settings.tolerance) : globalSelect(imageData, key, settings.tolerance), canvas.width, canvas.height, settings); + const existingAlpha = existingMaskSource ? await loadMaskAlpha(existingMaskSource, canvas.width, canvas.height) : undefined; + for (let pixel = 0; pixel < selected.length; pixel++) { + const current = existingAlpha?.[pixel] ?? 255; + const value = settings.mode === "add" ? (selected[pixel] ? 0 : current) : settings.mode === "subtract" ? (selected[pixel] ? 255 : current) : selected[pixel] ? 0 : 255; + const index = pixel * 4; + imageData.data[index] = 255; + imageData.data[index + 1] = 255; + imageData.data[index + 2] = 255; + imageData.data[index + 3] = value; + } + context.putImageData(imageData, 0, 0); + return canvas.toDataURL("image/png"); +} + +function floodSelect(data: ImageData, width: number, height: number, startX: number, startY: number, key: number[], tolerance: number) { + const selected = new Uint8Array(width * height); + const queue: Array<[number, number]> = [[startX, startY]]; + while (queue.length) { + const [x, y] = queue.pop()!; + if (x < 0 || y < 0 || x >= width || y >= height) continue; + const pixel = y * width + x; + if (selected[pixel]) continue; + if (!matches(data, pixel, key, tolerance)) continue; + selected[pixel] = 1; + queue.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]); + } + return selected; +} + +function globalSelect(data: ImageData, key: number[], tolerance: number) { + const selected = new Uint8Array(data.width * data.height); + for (let pixel = 0; pixel < selected.length; pixel++) if (matches(data, pixel, key, tolerance)) selected[pixel] = 1; + return selected; +} + +function matches(data: ImageData, pixel: number, key: number[], tolerance: number) { + const index = pixel * 4; + return Math.hypot((data.data[index] ?? 0) - (key[0] ?? 0), (data.data[index + 1] ?? 0) - (key[1] ?? 0), (data.data[index + 2] ?? 0) - (key[2] ?? 0)) <= tolerance; +} + +function postProcessSelection(selected: Uint8Array, width: number, height: number, settings: EditorState["tools"]["magicWand"]) { + let next = selected; + const despeckle = Math.round(Math.max(0, Math.min(20, settings.despeckle))); + const choke = Math.round(Math.max(-20, Math.min(20, settings.choke))); + const feather = Math.round(Math.max(0, Math.min(20, settings.feather))); + if (despeckle > 0) next = despeckleSelection(next, width, height, despeckle); + if (choke > 0) next = erodeSelection(next, width, height, choke); + if (choke < 0) next = dilateSelection(next, width, height, -choke); + if (feather > 0) next = featherSelection(next, width, height, feather); + return next; +} + +function erodeSelection(selected: Uint8Array, width: number, height: number, radius: number) { + const next = new Uint8Array(selected.length); + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + let value = 1; + for (let oy = -radius; oy <= radius; oy++) for (let ox = -radius; ox <= radius; ox++) value = Math.min(value, selected[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0); + next[y * width + x] = value; + } + return next; +} + +function dilateSelection(selected: Uint8Array, width: number, height: number, radius: number) { + const next = new Uint8Array(selected.length); + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + let value = 0; + for (let oy = -radius; oy <= radius; oy++) for (let ox = -radius; ox <= radius; ox++) value = Math.max(value, selected[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0); + next[y * width + x] = value; + } + return next; +} + +function featherSelection(selected: Uint8Array, width: number, height: number, radius: number) { + const next = new Uint8Array(selected.length); + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + let total = 0; + let count = 0; + for (let oy = -radius; oy <= radius; oy++) for (let ox = -radius; ox <= radius; ox++) { + total += selected[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0; + count += 1; + } + next[y * width + x] = Math.round(total / count); + } + return next; +} + +function despeckleSelection(selected: Uint8Array, width: number, height: number, strength: number) { + const radius = Math.max(1, Math.ceil(strength / 6)); + const threshold = Math.max(1, Math.round(strength / 2)); + const next = new Uint8Array(selected); + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + const index = y * width + x; + let same = 0; + for (let oy = -radius; oy <= radius; oy++) for (let ox = -radius; ox <= radius; ox++) if (ox !== 0 || oy !== 0) { + if ((selected[clamp(y + oy, 0, height - 1) * width + clamp(x + ox, 0, width - 1)] ?? 0) === selected[index]) same += 1; + } + if (same <= threshold) next[index] = selected[index] ? 0 : 1; + } + return next; +} + +async function loadMaskAlpha(source: string, width: number, height: number) { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) return undefined; + const image = await loadImage(source); + context.drawImage(image, 0, 0, width, height); + const data = context.getImageData(0, 0, width, height); + const alpha = new Uint8ClampedArray(width * height); + for (let pixel = 0; pixel < alpha.length; pixel++) alpha[pixel] = data.data[pixel * 4 + 3] ?? 255; + return alpha; +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined { + for (const layer of layers) { + if (layer.id === layerId) return layer; + if (layer.type === "group") { + const found = findLayer(layer.children, layerId); + if (found) return found; + } + } + return undefined; +} + +function loadImage(source: string) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error("Failed to load image")); + image.src = source; + }); +} diff --git a/view/canvas/useCanvasInput.ts b/view/canvas/useCanvasInput.ts index 27207c8..fdcf78a 100644 --- a/view/canvas/useCanvasInput.ts +++ b/view/canvas/useCanvasInput.ts @@ -13,6 +13,7 @@ import { wheelInputEventFromWheelEvent, } from "@input/index"; import { beginBrushSession, canPreviewBrush, commitBrushSession, updateBrushSession, type BrushSession } from "./brush"; +import { applyMagicWandAt } from "./magic-wand"; export type CanvasInputOptions = { globalKeybindConsumer: GlobalKeybindConsumer; @@ -116,6 +117,12 @@ export function useCanvasInput( return; } + if (state.editor.tools.activeTool === "magicWand") { + void applyMagicWandAt(store, documentPoint, inputEvent.shiftKey ? "add" : inputEvent.altKey ? "subtract" : undefined); + event.preventDefault(); + return; + } + const currentState = store.getState(); const selectionToolActive = currentState.editor.tools.activeTool === "select"; const selected = selectionToolActive && handleArtboardSelection({ diff --git a/view/toolLabels.ts b/view/toolLabels.ts index 3e54939..dd4eb37 100644 --- a/view/toolLabels.ts +++ b/view/toolLabels.ts @@ -6,6 +6,8 @@ export function labelForTool(tool: ToolId): string { return "Brush"; case "chromaKey": return "Chroma key"; + case "magicWand": + return "Magic wand"; case "eraser": return "Eraser"; case "pan":