feat: add magic wand tool with settings and controls, including UI integration and functionality
This commit is contained in:
78
README.md
78
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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<BrushSettings>;
|
||||
|
||||
export type ToolSetChromaKeySettingsPayload = Partial<ChromaKeySettings>;
|
||||
|
||||
export type ToolSetMagicWandSettingsPayload = Partial<MagicWandSettings>;
|
||||
|
||||
export type ToolSetBrushPreviewPayload = { position: Vec2D } | undefined;
|
||||
|
||||
export type ToolSetBrushStrokePreviewPayload =
|
||||
@@ -100,6 +102,30 @@ export const toolSetChromaKeySettingsCommand: Command<ToolSetChromaKeySettingsPa
|
||||
},
|
||||
};
|
||||
|
||||
export const toolSetMagicWandSettingsCommand: Command<ToolSetMagicWandSettingsPayload> = {
|
||||
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<ToolSetBrushPreviewPayload> = {
|
||||
id: commandIds.toolSetBrushPreview,
|
||||
name: "Set brush preview",
|
||||
@@ -250,6 +276,7 @@ export const toolCommands = [
|
||||
toolSetActiveCommand,
|
||||
toolSetBrushSettingsCommand,
|
||||
toolSetChromaKeySettingsCommand,
|
||||
toolSetMagicWandSettingsCommand,
|
||||
toolSetBrushPreviewCommand,
|
||||
toolSetBrushStrokePreviewCommand,
|
||||
toolSetMaskViewModeCommand,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,6 +6,7 @@ const toolKeybinds = {
|
||||
b: "brush",
|
||||
e: "eraser",
|
||||
k: "chromaKey",
|
||||
w: "magicWand",
|
||||
p: "pan",
|
||||
s: "select",
|
||||
} as const;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskViewMode={maskViewMode} dispatch={dispatch} />
|
||||
) : activeTool === "chromaKey" ? (
|
||||
<ChromaKeyControls document={document} selection={selection} settings={chromaKeySettings} dispatch={dispatch} />
|
||||
) : activeTool === "magicWand" ? (
|
||||
<MagicWandControls settings={magicWandSettings} dispatch={dispatch} />
|
||||
) : transformBounds && transformTarget ? (
|
||||
<TransformControls bounds={transformBounds} target={transformTarget} dispatch={dispatch} />
|
||||
) : action === "pan" ? (
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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":
|
||||
|
||||
34
view/bottom-controls/MagicWandControls.tsx
Normal file
34
view/bottom-controls/MagicWandControls.tsx
Normal file
@@ -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 (
|
||||
<div className={bottomControlMenuClass()}>
|
||||
<span className={bottomControlIconSlotClass()} title="Magic wand"><MagicWand size={24} /></span>
|
||||
<BottomControlDivider />
|
||||
<Slider label="Tol" value={settings.tolerance} min={0} max={255} onChange={(tolerance) => dispatch(commandIds.toolSetMagicWandSettings, { tolerance })} />
|
||||
<BottomControlDivider />
|
||||
<Slider label="Feather" value={settings.feather} min={0} max={20} onChange={(feather) => dispatch(commandIds.toolSetMagicWandSettings, { feather })} />
|
||||
<BottomControlDivider />
|
||||
<Slider label="Choke" value={settings.choke} min={-20} max={20} onChange={(choke) => dispatch(commandIds.toolSetMagicWandSettings, { choke })} />
|
||||
<BottomControlDivider />
|
||||
<Slider label="Clean" value={settings.despeckle} min={0} max={20} onChange={(despeckle) => dispatch(commandIds.toolSetMagicWandSettings, { despeckle })} />
|
||||
<BottomControlDivider />
|
||||
<button type="button" className={`rounded-full px-4 py-2 text-base font-medium transition ${settings.contiguous ? "bg-white text-black" : "bg-white/10 text-white"}`} onClick={() => dispatch(commandIds.toolSetMagicWandSettings, { contiguous: !settings.contiguous })}>Contig</button>
|
||||
<BottomControlDivider />
|
||||
{(["replace", "add", "subtract"] as const).map((mode) => (
|
||||
<button key={mode} type="button" className={`rounded-full px-4 py-2 text-base font-medium capitalize transition ${settings.mode === mode ? "bg-white text-black" : "bg-white/10 text-white"}`} onClick={() => dispatch(commandIds.toolSetMagicWandSettings, { mode })}>{mode === "subtract" ? "Sub" : mode}</button>
|
||||
))}
|
||||
<span className="px-2 text-sm text-white/60">Shift-click adds, Alt-click subtracts</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Slider({ label, value, min, max, onChange }: { label: string; value: number; min: number; max: number; onChange: (value: number) => void }) {
|
||||
return <label className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>{label}</span><BottomControlSlider min={min} max={max} value={value} className="w-32" aria-label={`Magic wand ${label}`} onValueChange={onChange} /><span className="w-8 text-right text-base text-white">{Math.round(value)}</span></label>;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
197
view/canvas/magic-wand.ts
Normal file
197
view/canvas/magic-wand.ts
Normal file
@@ -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<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error("Failed to load image"));
|
||||
image.src = source;
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user