feat: add feather brush tool with adjustable settings and blending functionality
- Implemented feather brush tool in the brushRaster module, allowing for feathered edges in brush strokes. - Added new FeatherControls component for UI adjustments of feather settings including size, radius, strength, and smoothing. - Updated brush preview logic to accommodate feather tool alongside existing brush and eraser tools. - Enhanced layer rendering to support feather mask previews and interactions. - Introduced blending logic for feathered strokes to mix blurred mask values with original pixels. - Added unit tests for feather blending functionality and tool keybindings. - Updated cursor handling to reflect feather tool usage.
This commit is contained in:
@@ -141,6 +141,7 @@ export type DocumentAddLayerMaskPayload = {
|
|||||||
layerId: LayerId;
|
layerId: LayerId;
|
||||||
asset: Asset;
|
asset: Asset;
|
||||||
maskLayer: RasterLayer;
|
maskLayer: RasterLayer;
|
||||||
|
activeTool?: "brush" | "feather";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LayerMaskOperation =
|
export type LayerMaskOperation =
|
||||||
@@ -645,8 +646,8 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
|
|||||||
maskEdit: { kind: "layerMask", targetLayerId: payload.layerId, maskLayerId: existingMaskId, maskAssetId: existingMaskLayer.assetId },
|
maskEdit: { kind: "layerMask", targetLayerId: payload.layerId, maskLayerId: existingMaskId, maskAssetId: existingMaskLayer.assetId },
|
||||||
tools: {
|
tools: {
|
||||||
...state.editor.tools,
|
...state.editor.tools,
|
||||||
activeTool: "brush",
|
activeTool: payload.activeTool ?? "brush",
|
||||||
interactionMode: { type: "tool", tool: "brush" },
|
interactionMode: { type: "tool", tool: payload.activeTool ?? "brush" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -678,8 +679,8 @@ export const documentAddLayerMaskCommand: Command<DocumentAddLayerMaskPayload> =
|
|||||||
maskEdit: { kind: "layerMask", targetLayerId: payload.layerId, maskLayerId: maskLayer.id, maskAssetId: payload.asset.id },
|
maskEdit: { kind: "layerMask", targetLayerId: payload.layerId, maskLayerId: maskLayer.id, maskAssetId: payload.asset.id },
|
||||||
tools: {
|
tools: {
|
||||||
...state.editor.tools,
|
...state.editor.tools,
|
||||||
activeTool: "brush",
|
activeTool: payload.activeTool ?? "brush",
|
||||||
interactionMode: { type: "tool", tool: "brush" },
|
interactionMode: { type: "tool", tool: payload.activeTool ?? "brush" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export const commandIds = {
|
|||||||
toolSetGenerateSettings: "tool.setGenerateSettings",
|
toolSetGenerateSettings: "tool.setGenerateSettings",
|
||||||
toolChooseGenerateIntent: "tool.chooseGenerateIntent",
|
toolChooseGenerateIntent: "tool.chooseGenerateIntent",
|
||||||
toolSetBrushSettings: "tool.setBrushSettings",
|
toolSetBrushSettings: "tool.setBrushSettings",
|
||||||
|
toolSetFeatherSettings: "tool.setFeatherSettings",
|
||||||
toolSetChromaKeySettings: "tool.setChromaKeySettings",
|
toolSetChromaKeySettings: "tool.setChromaKeySettings",
|
||||||
toolSetMagicWandSettings: "tool.setMagicWandSettings",
|
toolSetMagicWandSettings: "tool.setMagicWandSettings",
|
||||||
toolSetBrushPreview: "tool.setBrushPreview",
|
toolSetBrushPreview: "tool.setBrushPreview",
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ import type {
|
|||||||
CommandPaletteSetSelectedIndexPayload,
|
CommandPaletteSetSelectedIndexPayload,
|
||||||
} from "./palette";
|
} from "./palette";
|
||||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||||
import type { ToolAppendMaskShapePayload, ToolBeginMaskShapePayload, ToolChooseGenerateIntentPayload, ToolEnterInpaintRegionEditPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
import type { ToolAppendMaskShapePayload, ToolBeginMaskShapePayload, ToolChooseGenerateIntentPayload, ToolEnterInpaintRegionEditPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetFeatherSettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool";
|
||||||
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformSetRotationPayload, TransformUpdatePayload } from "./transform";
|
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformSetRotationPayload, TransformUpdatePayload } from "./transform";
|
||||||
import type { WorkspaceSetPanelPayload } from "./workspace";
|
import type { WorkspaceSetPanelPayload } from "./workspace";
|
||||||
import type { EditorSetPointerSessionPayload } from "./editor";
|
import type { EditorSetPointerSessionPayload } from "./editor";
|
||||||
@@ -110,6 +110,7 @@ export type CommandPayloads = {
|
|||||||
[commandIds.toolSetGenerateSettings]: ToolSetGenerateSettingsPayload;
|
[commandIds.toolSetGenerateSettings]: ToolSetGenerateSettingsPayload;
|
||||||
[commandIds.toolChooseGenerateIntent]: ToolChooseGenerateIntentPayload;
|
[commandIds.toolChooseGenerateIntent]: ToolChooseGenerateIntentPayload;
|
||||||
[commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload;
|
[commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload;
|
||||||
|
[commandIds.toolSetFeatherSettings]: ToolSetFeatherSettingsPayload;
|
||||||
[commandIds.toolSetChromaKeySettings]: ToolSetChromaKeySettingsPayload;
|
[commandIds.toolSetChromaKeySettings]: ToolSetChromaKeySettingsPayload;
|
||||||
[commandIds.toolSetMagicWandSettings]: ToolSetMagicWandSettingsPayload;
|
[commandIds.toolSetMagicWandSettings]: ToolSetMagicWandSettingsPayload;
|
||||||
[commandIds.toolSetBrushPreview]: ToolSetBrushPreviewPayload;
|
[commandIds.toolSetBrushPreview]: ToolSetBrushPreviewPayload;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { createInitialAppState } from "@editor/initial-state";
|
import { createInitialAppState } from "@editor/initial-state";
|
||||||
import { initialToolState } from "@editor/tools";
|
import { initialToolState } from "@editor/tools";
|
||||||
import { toolAppendMaskShapeCommand, toolBeginMaskShapeCommand, toolChooseGenerateIntentCommand, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
|
import { toolAppendMaskShapeCommand, toolBeginMaskShapeCommand, toolChooseGenerateIntentCommand, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetFeatherSettingsCommand, toolSetGenerateSettingsCommand, toolSetMaskViewModeCommand } from "./tool";
|
||||||
|
|
||||||
const defaultBrush = { color: "#111827", size: 8, hardness: 100, opacity: 100, flow: 100, smoothing: 20, pressureSize: true };
|
const defaultBrush = { color: "#111827", size: 8, hardness: 100, opacity: 100, flow: 100, smoothing: 20, pressureSize: true };
|
||||||
|
const defaultFeather = { size: 96, radius: 16, strength: 65, smoothing: 25, pressureSize: true };
|
||||||
const defaultChromaKey = { color: "#00ff00", tolerance: 32, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 };
|
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 };
|
const defaultMagicWand = { tolerance: 32, feather: 0, choke: 0, despeckle: 0, contiguous: true, mode: "replace" as const };
|
||||||
const defaultGenerate = initialToolState.generate;
|
const defaultGenerate = initialToolState.generate;
|
||||||
@@ -11,7 +12,7 @@ const defaultGenerate = initialToolState.generate;
|
|||||||
describe("tool commands", () => {
|
describe("tool commands", () => {
|
||||||
test("sets active tool", () => {
|
test("sets active tool", () => {
|
||||||
const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "brush" });
|
const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "brush" });
|
||||||
expect(next.editor.tools).toEqual({ activeTool: "brush", interactionMode: { type: "tool", tool: "brush" }, brush: defaultBrush, generate: defaultGenerate, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
|
expect(next.editor.tools).toEqual({ activeTool: "brush", interactionMode: { type: "tool", tool: "brush" }, brush: defaultBrush, feather: defaultFeather, generate: defaultGenerate, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("selecting a persistent tool closes an open operation", () => {
|
test("selecting a persistent tool closes an open operation", () => {
|
||||||
@@ -28,6 +29,12 @@ describe("tool commands", () => {
|
|||||||
expect(next.editor.tools.brush).toEqual({ ...defaultBrush, color: "#ff0000", size: 24, hardness: 50 });
|
expect(next.editor.tools.brush).toEqual({ ...defaultBrush, color: "#ff0000", size: 24, hardness: 50 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("sets and clamps feather settings", () => {
|
||||||
|
const next = toolSetFeatherSettingsCommand.execute({ state: createInitialAppState("Test") }, { size: 800, radius: 0, strength: 45 });
|
||||||
|
|
||||||
|
expect(next.editor.tools.feather).toEqual({ ...defaultFeather, size: 400, radius: 1, strength: 45 });
|
||||||
|
});
|
||||||
|
|
||||||
test("sets chroma key settings", () => {
|
test("sets chroma key settings", () => {
|
||||||
const next = toolSetChromaKeySettingsCommand.execute({ state: createInitialAppState("Test") }, { color: "#123456", tolerance: 300 });
|
const next = toolSetChromaKeySettingsCommand.execute({ state: createInitialAppState("Test") }, { color: "#123456", tolerance: 300 });
|
||||||
|
|
||||||
@@ -134,7 +141,7 @@ describe("tool commands", () => {
|
|||||||
const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined);
|
const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined);
|
||||||
const restored = toolExitTemporaryPanCommand.execute({ state: panning }, undefined);
|
const restored = toolExitTemporaryPanCommand.execute({ state: panning }, undefined);
|
||||||
|
|
||||||
expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: defaultBrush, generate: defaultGenerate, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
|
expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: defaultBrush, feather: defaultFeather, generate: defaultGenerate, chromaKey: defaultChromaKey, magicWand: defaultMagicWand });
|
||||||
expect(restored.editor.tools).toEqual(initial.editor.tools);
|
expect(restored.editor.tools).toEqual(initial.editor.tools);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { Layer } from "@core/layer";
|
|||||||
import { getLayerMask } from "@core/layer-mask-utils";
|
import { getLayerMask } from "@core/layer-mask-utils";
|
||||||
import type { MaskViewMode } from "@editor/state";
|
import type { MaskViewMode } from "@editor/state";
|
||||||
import { generateArchitectureDefaults, inpaintProfileDefaults } from "@editor/tools";
|
import { generateArchitectureDefaults, inpaintProfileDefaults } from "@editor/tools";
|
||||||
import type { BrushSettings, ChromaKeySettings, GenerateIntent, GenerateSettings, MagicWandSettings, ToolId } from "@editor/tools";
|
import type { BrushSettings, ChromaKeySettings, FeatherSettings, GenerateIntent, GenerateSettings, MagicWandSettings, ToolId } from "@editor/tools";
|
||||||
import type { Command } from "./command";
|
import type { Command } from "./command";
|
||||||
import { commandIds } from "./ids";
|
import { commandIds } from "./ids";
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ export type ToolSetActivePayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
|
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
|
||||||
|
export type ToolSetFeatherSettingsPayload = Partial<FeatherSettings>;
|
||||||
|
|
||||||
export type ToolSetGenerateSettingsPayload = Omit<Partial<GenerateSettings>, "inpaint" | "outpaint"> & {
|
export type ToolSetGenerateSettingsPayload = Omit<Partial<GenerateSettings>, "inpaint" | "outpaint"> & {
|
||||||
inpaint?: Partial<GenerateSettings["inpaint"]>;
|
inpaint?: Partial<GenerateSettings["inpaint"]>;
|
||||||
@@ -32,6 +33,8 @@ export type ToolSetBrushStrokePreviewPayload =
|
|||||||
layerId: LayerId;
|
layerId: LayerId;
|
||||||
assetId: AssetId;
|
assetId: AssetId;
|
||||||
source: string;
|
source: string;
|
||||||
|
pendingTargetLayerId?: LayerId;
|
||||||
|
intrinsicSize?: { w: number; h: number };
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
@@ -187,6 +190,29 @@ export const toolSetBrushSettingsCommand: Command<ToolSetBrushSettingsPayload> =
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const toolSetFeatherSettingsCommand: Command<ToolSetFeatherSettingsPayload> = {
|
||||||
|
id: commandIds.toolSetFeatherSettings,
|
||||||
|
name: "Set feather settings",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
tools: {
|
||||||
|
...state.editor.tools,
|
||||||
|
feather: {
|
||||||
|
size: clampNumber(payload.size ?? state.editor.tools.feather.size, 1, 400),
|
||||||
|
radius: clampNumber(payload.radius ?? state.editor.tools.feather.radius, 1, 128),
|
||||||
|
strength: clampNumber(payload.strength ?? state.editor.tools.feather.strength, 1, 100),
|
||||||
|
smoothing: clampNumber(payload.smoothing ?? state.editor.tools.feather.smoothing, 0, 100),
|
||||||
|
pressureSize: payload.pressureSize ?? state.editor.tools.feather.pressureSize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const toolSetChromaKeySettingsCommand: Command<ToolSetChromaKeySettingsPayload> = {
|
export const toolSetChromaKeySettingsCommand: Command<ToolSetChromaKeySettingsPayload> = {
|
||||||
id: commandIds.toolSetChromaKeySettings,
|
id: commandIds.toolSetChromaKeySettings,
|
||||||
name: "Set chroma key settings",
|
name: "Set chroma key settings",
|
||||||
@@ -274,7 +300,13 @@ export const toolSetBrushStrokePreviewCommand: Command<ToolSetBrushStrokePreview
|
|||||||
...state,
|
...state,
|
||||||
editor: {
|
editor: {
|
||||||
...state.editor,
|
...state.editor,
|
||||||
brushStrokePreview: { layerId: payload.layerId, assetId: payload.assetId, source: payload.source },
|
brushStrokePreview: {
|
||||||
|
layerId: payload.layerId,
|
||||||
|
assetId: payload.assetId,
|
||||||
|
source: payload.source,
|
||||||
|
pendingTargetLayerId: payload.pendingTargetLayerId,
|
||||||
|
intrinsicSize: payload.intrinsicSize,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -454,6 +486,7 @@ export const toolCommands = [
|
|||||||
toolSetGenerateSettingsCommand,
|
toolSetGenerateSettingsCommand,
|
||||||
toolChooseGenerateIntentCommand,
|
toolChooseGenerateIntentCommand,
|
||||||
toolSetBrushSettingsCommand,
|
toolSetBrushSettingsCommand,
|
||||||
|
toolSetFeatherSettingsCommand,
|
||||||
toolSetChromaKeySettingsCommand,
|
toolSetChromaKeySettingsCommand,
|
||||||
toolSetMagicWandSettingsCommand,
|
toolSetMagicWandSettingsCommand,
|
||||||
toolSetBrushPreviewCommand,
|
toolSetBrushPreviewCommand,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type { AppState, EditorState, SelectionState, ViewportState } from "./state";
|
export type { AppState, EditorState, SelectionState, ViewportState } from "./state";
|
||||||
export type { BrushSettings, ChromaKeySettings, MagicWandSettings, InteractionMode, OperationId, ToolId, ToolState } from "./tools";
|
export type { BrushSettings, ChromaKeySettings, FeatherSettings, MagicWandSettings, InteractionMode, OperationId, ToolId, ToolState } from "./tools";
|
||||||
export { initialToolState } from "./tools";
|
export { initialToolState } from "./tools";
|
||||||
export { createInitialAppState, initialEditorState } from "./initial-state";
|
export { createInitialAppState, initialEditorState } from "./initial-state";
|
||||||
export type { AppStore, StateListener } from "./store";
|
export type { AppStore, StateListener } from "./store";
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export type BrushStrokePreviewState = {
|
|||||||
layerId: LayerId;
|
layerId: LayerId;
|
||||||
assetId: AssetId;
|
assetId: AssetId;
|
||||||
source: string;
|
source: string;
|
||||||
|
pendingTargetLayerId?: LayerId;
|
||||||
|
intrinsicSize?: Size;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MaskShapeSession = {
|
export type MaskShapeSession = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const availableToolIds = ["select", "brush", "eraser", "magicWand", "semanticSelect", "maskLasso", "maskRectangle", "pan"] as const;
|
export const availableToolIds = ["select", "brush", "eraser", "feather", "magicWand", "semanticSelect", "maskLasso", "maskRectangle", "pan"] as const;
|
||||||
|
|
||||||
export const availableOperationIds = ["generate", "chromaKey"] as const;
|
export const availableOperationIds = ["generate", "chromaKey"] as const;
|
||||||
|
|
||||||
@@ -19,6 +19,14 @@ export type BrushSettings = {
|
|||||||
pressureSize: boolean;
|
pressureSize: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FeatherSettings = {
|
||||||
|
size: number;
|
||||||
|
radius: number;
|
||||||
|
strength: number;
|
||||||
|
smoothing: number;
|
||||||
|
pressureSize: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type ChromaKeySettings = {
|
export type ChromaKeySettings = {
|
||||||
color: string;
|
color: string;
|
||||||
tolerance: number;
|
tolerance: number;
|
||||||
@@ -115,6 +123,7 @@ export type ToolState = {
|
|||||||
activeTool: ToolId;
|
activeTool: ToolId;
|
||||||
interactionMode: InteractionMode;
|
interactionMode: InteractionMode;
|
||||||
brush: BrushSettings;
|
brush: BrushSettings;
|
||||||
|
feather: FeatherSettings;
|
||||||
chromaKey: ChromaKeySettings;
|
chromaKey: ChromaKeySettings;
|
||||||
magicWand: MagicWandSettings;
|
magicWand: MagicWandSettings;
|
||||||
generate: GenerateSettings;
|
generate: GenerateSettings;
|
||||||
@@ -124,6 +133,7 @@ export const initialToolState: ToolState = {
|
|||||||
activeTool: "select",
|
activeTool: "select",
|
||||||
interactionMode: { type: "tool", tool: "select" },
|
interactionMode: { type: "tool", tool: "select" },
|
||||||
brush: { color: "#111827", size: 8, hardness: 100, opacity: 100, flow: 100, smoothing: 20, pressureSize: true },
|
brush: { color: "#111827", size: 8, hardness: 100, opacity: 100, flow: 100, smoothing: 20, pressureSize: true },
|
||||||
|
feather: { size: 96, radius: 16, strength: 65, smoothing: 25, pressureSize: true },
|
||||||
chromaKey: { color: "#00ff00", tolerance: 32, softness: 24, feather: 0, choke: 0, despeckle: 0, spill: 50 },
|
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" },
|
magicWand: { tolerance: 32, feather: 0, choke: 0, despeckle: 0, contiguous: true, mode: "replace" },
|
||||||
generate: {
|
generate: {
|
||||||
|
|||||||
20
input/tool-keybinds.test.ts
Normal file
20
input/tool-keybinds.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { commandIds } from "@commands/ids";
|
||||||
|
import type { Dispatch } from "@commands/dispatcher";
|
||||||
|
import { handleToolKey } from "./tool-keybinds";
|
||||||
|
|
||||||
|
describe("tool keybinds", () => {
|
||||||
|
test("selects the feather tool with F", () => {
|
||||||
|
const dispatched: unknown[] = [];
|
||||||
|
const consumed = handleToolKey({
|
||||||
|
event: { key: "f", code: "KeyF", 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.toolSetActive, payload: { tool: "feather" } }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import type { KeybindEvent } from "./keyboard";
|
|||||||
const toolKeybinds = {
|
const toolKeybinds = {
|
||||||
b: "brush",
|
b: "brush",
|
||||||
e: "eraser",
|
e: "eraser",
|
||||||
|
f: "feather",
|
||||||
w: "magicWand",
|
w: "magicWand",
|
||||||
p: "pan",
|
p: "pan",
|
||||||
s: "select",
|
s: "select",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type { PointerInputEvent } from "./pointer";
|
|||||||
|
|
||||||
type TransformHandle = "body" | "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
|
type TransformHandle = "body" | "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
|
||||||
|
|
||||||
type InputToolId = "select" | "brush" | "eraser" | "magicWand" | "semanticSelect" | "maskLasso" | "maskRectangle" | "pan";
|
type InputToolId = "select" | "brush" | "eraser" | "feather" | "magicWand" | "semanticSelect" | "maskLasso" | "maskRectangle" | "pan";
|
||||||
|
|
||||||
type InputInteractionMode =
|
type InputInteractionMode =
|
||||||
| { type: "tool"; tool: InputToolId }
|
| { type: "tool"; tool: InputToolId }
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Vec2D } from "@core/geometry";
|
import type { Vec2D } from "@core/geometry";
|
||||||
|
import type { AssetId, LayerId } from "@core/id";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
|
import { getLayerMask } from "@core/layer-mask-utils";
|
||||||
import type { RasterLayer } from "@core/raster-layer";
|
import type { RasterLayer } from "@core/raster-layer";
|
||||||
import type { MaskEditState, SelectionState } from "@editor/state";
|
import type { MaskEditState, SelectionState } from "@editor/state";
|
||||||
import { isPanInteractionMode, type ToolState } from "@editor/tools";
|
import { isPanInteractionMode, type ToolState } from "@editor/tools";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import { brushSurfaceDataUrl, brushSurfaceObjectUrl, cancelFrame, createBrushSurface, drawBrushSegment, releaseObjectUrl, scheduleFrame, type BrushSurface } from "@platform/browser/brushRaster";
|
import { brushSurfaceDataUrl, brushSurfaceObjectUrl, cancelFrame, createBrushSurface, drawBrushSegment, drawFeatherSegment, releaseObjectUrl, scheduleFrame, type BrushSurface } from "@platform/browser/brushRaster";
|
||||||
|
|
||||||
export type BrushSession = {
|
export type BrushSession = {
|
||||||
layerId: string;
|
layerId: string;
|
||||||
@@ -16,7 +18,7 @@ export type BrushSession = {
|
|||||||
surface: BrushSurface;
|
surface: BrushSurface;
|
||||||
ready: Promise<boolean>;
|
ready: Promise<boolean>;
|
||||||
previousPoint: Vec2D;
|
previousPoint: Vec2D;
|
||||||
mode: "brush" | "eraser";
|
mode: "brush" | "eraser" | "feather";
|
||||||
changed?: boolean;
|
changed?: boolean;
|
||||||
pending?: Promise<void>;
|
pending?: Promise<void>;
|
||||||
cancelled?: boolean;
|
cancelled?: boolean;
|
||||||
@@ -26,6 +28,12 @@ export type BrushSession = {
|
|||||||
previewFrame?: number;
|
previewFrame?: number;
|
||||||
previewSource?: string;
|
previewSource?: string;
|
||||||
targetLayer: RasterLayer;
|
targetLayer: RasterLayer;
|
||||||
|
featherRadius?: number;
|
||||||
|
pendingLayerMask?: {
|
||||||
|
targetLayerId: LayerId;
|
||||||
|
asset: ImageDocument["assets"][number];
|
||||||
|
maskLayer: RasterLayer;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BrushTargetEditorState = {
|
export type BrushTargetEditorState = {
|
||||||
@@ -36,7 +44,7 @@ export type BrushTargetEditorState = {
|
|||||||
|
|
||||||
export function beginBrushSession(document: ImageDocument, editor: BrushTargetEditorState, point: Vec2D): BrushSession | undefined {
|
export function beginBrushSession(document: ImageDocument, editor: BrushTargetEditorState, point: Vec2D): BrushSession | undefined {
|
||||||
const target = resolveBrushTarget(document, editor);
|
const target = resolveBrushTarget(document, editor);
|
||||||
if (!target || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
if (!target || !isPaintTool(editor.tools.activeTool)) return undefined;
|
||||||
const { layer, asset } = target;
|
const { layer, asset } = target;
|
||||||
|
|
||||||
const surface = createBrushSurface(asset.intrinsicSize.w, asset.intrinsicSize.h, asset.source);
|
const surface = createBrushSurface(asset.intrinsicSize.w, asset.intrinsicSize.h, asset.source);
|
||||||
@@ -52,17 +60,19 @@ export function beginBrushSession(document: ImageDocument, editor: BrushTargetEd
|
|||||||
previousPoint: point,
|
previousPoint: point,
|
||||||
mode: editor.tools.activeTool,
|
mode: editor.tools.activeTool,
|
||||||
targetLayer: layer,
|
targetLayer: layer,
|
||||||
|
pendingLayerMask: target.pendingLayerMask,
|
||||||
};
|
};
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canPreviewBrush(document: ImageDocument, editor: BrushTargetEditorState): boolean {
|
export function canPreviewBrush(document: ImageDocument, editor: BrushTargetEditorState): boolean {
|
||||||
|
if (editor.tools.activeTool === "feather" && !editor.maskEdit) return Boolean(resolveSelectedFeatherLayer(document, editor));
|
||||||
return Boolean(resolveBrushTarget(document, editor));
|
return Boolean(resolveBrushTarget(document, editor));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function brushUnavailableHint(document: ImageDocument, editor: BrushTargetEditorState): string | undefined {
|
export function brushUnavailableHint(document: ImageDocument, editor: BrushTargetEditorState): string | undefined {
|
||||||
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
if (isPanInteractionMode(editor.tools.interactionMode) || !isPaintTool(editor.tools.activeTool)) return undefined;
|
||||||
if (resolveBrushTarget(document, editor)) return undefined;
|
if (canPreviewBrush(document, editor)) return undefined;
|
||||||
|
|
||||||
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
|
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
|
||||||
if (!layerId) {
|
if (!layerId) {
|
||||||
@@ -73,14 +83,14 @@ export function brushUnavailableHint(document: ImageDocument, editor: BrushTarge
|
|||||||
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
|
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
|
||||||
if (!layer) return "Select a raster layer or layer mask to paint.";
|
if (!layer) return "Select a raster layer or layer mask to paint.";
|
||||||
if (layer.locked) return "Unlock this layer before painting.";
|
if (layer.locked) return "Unlock this layer before painting.";
|
||||||
if (layer.type === "image") return "Image layers are non-destructive. Add a layer mask to paint or erase.";
|
if (layer.type === "image" && editor.tools.activeTool !== "feather") return "Image layers are non-destructive. Add a layer mask to paint or erase.";
|
||||||
if (layer.type === "group") return "Select a raster layer inside the group to paint.";
|
if (layer.type === "group") return "Select a raster layer inside the group to paint.";
|
||||||
if (!editor.maskEdit && !layer.visible) return "Show this layer before painting.";
|
if (!editor.maskEdit && !layer.visible) return "Show this layer before painting.";
|
||||||
return "Select a raster layer or layer mask to paint.";
|
return "Select a raster layer or layer mask to paint.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBrushTarget(document: ImageDocument, editor: BrushTargetEditorState): { layer: RasterLayer; asset: ImageDocument["assets"][number] } | undefined {
|
function resolveBrushTarget(document: ImageDocument, editor: BrushTargetEditorState): { layer: RasterLayer; asset: ImageDocument["assets"][number]; pendingLayerMask?: BrushSession["pendingLayerMask"] } | undefined {
|
||||||
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
if (isPanInteractionMode(editor.tools.interactionMode) || !isPaintTool(editor.tools.activeTool)) return undefined;
|
||||||
if (editor.maskEdit?.kind === "inpaintRegion") {
|
if (editor.maskEdit?.kind === "inpaintRegion") {
|
||||||
const target = findLayer(document.artboards.flatMap((artboard) => artboard.layers), editor.maskEdit.targetLayerId);
|
const target = findLayer(document.artboards.flatMap((artboard) => artboard.layers), editor.maskEdit.targetLayerId);
|
||||||
const asset = document.assets.find((candidate) => candidate.id === editor.maskEdit?.maskAssetId);
|
const asset = document.assets.find((candidate) => candidate.id === editor.maskEdit?.maskAssetId);
|
||||||
@@ -93,8 +103,51 @@ function resolveBrushTarget(document: ImageDocument, editor: BrushTargetEditorSt
|
|||||||
const editingMask = Boolean(editor.maskEdit);
|
const editingMask = Boolean(editor.maskEdit);
|
||||||
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
|
const layerId = editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
|
||||||
if (!layerId) return undefined;
|
if (!layerId) return undefined;
|
||||||
const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
|
const layers = document.artboards.flatMap((artboard) => artboard.layers);
|
||||||
if (!layer || layer.locked || (!editingMask && !layer.visible)) return undefined;
|
const selectedLayer = findLayer(layers, layerId);
|
||||||
|
if (!selectedLayer || selectedLayer.locked || (!editingMask && !selectedLayer.visible)) return undefined;
|
||||||
|
|
||||||
|
if (editor.tools.activeTool === "feather" && !editingMask && (selectedLayer.type === "image" || selectedLayer.type === "raster")) {
|
||||||
|
const maskLayerId = getLayerMask(selectedLayer)?.maskLayerId;
|
||||||
|
const attachedMask = maskLayerId ? findLayer(layers, maskLayerId) : undefined;
|
||||||
|
const attachedMaskLayer = attachedMask && (attachedMask.type === "image" || attachedMask.type === "raster") ? { ...attachedMask, type: "raster" as const } : undefined;
|
||||||
|
const maskAsset = attachedMaskLayer ? document.assets.find((candidate) => candidate.id === attachedMaskLayer.assetId) : undefined;
|
||||||
|
if (attachedMaskLayer && maskAsset) return { layer: attachedMaskLayer, asset: maskAsset };
|
||||||
|
|
||||||
|
const sourceAsset = document.assets.find((candidate) => candidate.id === selectedLayer.assetId);
|
||||||
|
if (!sourceAsset) return undefined;
|
||||||
|
const width = Math.max(1, Math.round(sourceAsset.intrinsicSize.w));
|
||||||
|
const height = Math.max(1, Math.round(sourceAsset.intrinsicSize.h));
|
||||||
|
const source = opaqueMaskSource(width, height);
|
||||||
|
const assetId = crypto.randomUUID() as AssetId;
|
||||||
|
const newMaskLayerId = crypto.randomUUID() as LayerId;
|
||||||
|
const sourceRect = selectedLayer.sourceRect ?? { x: 0, y: 0, w: width, h: height };
|
||||||
|
const maskLayer: RasterLayer = {
|
||||||
|
id: newMaskLayerId,
|
||||||
|
type: "raster",
|
||||||
|
name: `${selectedLayer.name} Mask`,
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
opacity: 1,
|
||||||
|
assetId,
|
||||||
|
transform: {
|
||||||
|
position: {
|
||||||
|
x: selectedLayer.transform.position.x + sourceRect.x * selectedLayer.transform.scale.x,
|
||||||
|
y: selectedLayer.transform.position.y + sourceRect.y * selectedLayer.transform.scale.y,
|
||||||
|
},
|
||||||
|
scale: {
|
||||||
|
x: (sourceRect.w * selectedLayer.transform.scale.x) / width,
|
||||||
|
y: (sourceRect.h * selectedLayer.transform.scale.y) / height,
|
||||||
|
},
|
||||||
|
rotation: selectedLayer.transform.rotation,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const asset = { id: assetId, name: maskLayer.name, mimeType: "image/svg+xml", source, intrinsicSize: { w: width, h: height } };
|
||||||
|
return { layer: maskLayer, asset, pendingLayerMask: { targetLayerId: selectedLayer.id, asset, maskLayer } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const layer = selectedLayer.type === "raster" ? selectedLayer : undefined;
|
||||||
|
if (!layer) return undefined;
|
||||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||||
return asset ? { layer, asset } : undefined;
|
return asset ? { layer, asset } : undefined;
|
||||||
}
|
}
|
||||||
@@ -111,10 +164,12 @@ export function updateBrushSession(options: {
|
|||||||
smoothing: number;
|
smoothing: number;
|
||||||
pressure: number;
|
pressure: number;
|
||||||
pressureSize: boolean;
|
pressureSize: boolean;
|
||||||
|
featherRadius?: number;
|
||||||
|
featherStrength?: number;
|
||||||
}): BrushSession {
|
}): BrushSession {
|
||||||
const state = options.store.getState();
|
const state = options.store.getState();
|
||||||
const asset = state.document.assets.find((candidate) => candidate.id === options.session.assetId);
|
const asset = state.document.assets.find((candidate) => candidate.id === options.session.assetId);
|
||||||
if (!asset) return options.session;
|
if (!asset && !options.session.pendingLayerMask) return options.session;
|
||||||
const layer = options.session.targetLayer;
|
const layer = options.session.targetLayer;
|
||||||
|
|
||||||
const from = options.session.previousPoint;
|
const from = options.session.previousPoint;
|
||||||
@@ -127,16 +182,30 @@ export function updateBrushSession(options: {
|
|||||||
if (options.session.cancelled) return;
|
if (options.session.cancelled) return;
|
||||||
if (!(await options.session.ready) || options.session.cancelled) return;
|
if (!(await options.session.ready) || options.session.cancelled) return;
|
||||||
|
|
||||||
drawBrushSegment(options.session.surface, {
|
const assetFrom = documentPointToAssetPoint(from, layer, options.session.width, options.session.height);
|
||||||
from: documentPointToAssetPoint(from, layer, options.session.width, options.session.height),
|
const assetTo = documentPointToAssetPoint(to, layer, options.session.width, options.session.height);
|
||||||
to: documentPointToAssetPoint(to, layer, options.session.width, options.session.height),
|
const size = options.size * (options.pressureSize ? Math.max(0.1, options.pressure) : 1);
|
||||||
color: state.editor.maskEdit ? "#ffffff" : options.color,
|
if (options.session.mode === "feather") {
|
||||||
size: options.size * (options.pressureSize ? Math.max(0.1, options.pressure) : 1),
|
options.session.featherRadius = options.featherRadius ?? 16;
|
||||||
hardness: options.hardness,
|
drawFeatherSegment(options.session.surface, {
|
||||||
opacity: options.opacity,
|
from: assetFrom,
|
||||||
flow: options.flow,
|
to: assetTo,
|
||||||
mode: options.session.mode,
|
size,
|
||||||
});
|
radius: options.featherRadius ?? 16,
|
||||||
|
strength: options.featherStrength ?? 65,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
drawBrushSegment(options.session.surface, {
|
||||||
|
from: assetFrom,
|
||||||
|
to: assetTo,
|
||||||
|
color: state.editor.maskEdit ? "#ffffff" : options.color,
|
||||||
|
size,
|
||||||
|
hardness: options.hardness,
|
||||||
|
opacity: options.opacity,
|
||||||
|
flow: options.flow,
|
||||||
|
mode: options.session.mode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (options.session.cancelled) return;
|
if (options.session.cancelled) return;
|
||||||
options.session.changed = true;
|
options.session.changed = true;
|
||||||
@@ -155,10 +224,22 @@ export async function commitBrushSession(options: { store: AppStore; session: Br
|
|||||||
if (source) {
|
if (source) {
|
||||||
const state = options.store.getState();
|
const state = options.store.getState();
|
||||||
const maskEdit = state.editor.maskEdit;
|
const maskEdit = state.editor.maskEdit;
|
||||||
if (maskEdit?.kind === "inpaintRegion" && maskEdit.inpaintRegionId && maskEdit.maskAssetId === options.session.assetId) {
|
const operation = options.session.mode === "feather"
|
||||||
options.store.dispatch(commandIds.documentApplyInpaintRegionMaskOperation, { regionId: maskEdit.inpaintRegionId, source, mimeType: "image/png", operation: { type: "paint" } });
|
? { type: "feather" as const, radius: Math.round(options.session.featherRadius ?? 16) }
|
||||||
|
: { type: "paint" as const };
|
||||||
|
if (options.session.pendingLayerMask) {
|
||||||
|
options.store.dispatch(commandIds.documentAddLayerMask, {
|
||||||
|
layerId: options.session.pendingLayerMask.targetLayerId,
|
||||||
|
asset: { ...options.session.pendingLayerMask.asset, source, mimeType: "image/png" },
|
||||||
|
maskLayer: options.session.pendingLayerMask.maskLayer,
|
||||||
|
activeTool: "feather",
|
||||||
|
});
|
||||||
|
} else if (maskEdit?.kind === "inpaintRegion" && maskEdit.inpaintRegionId && maskEdit.maskAssetId === options.session.assetId) {
|
||||||
|
options.store.dispatch(commandIds.documentApplyInpaintRegionMaskOperation, { regionId: maskEdit.inpaintRegionId, source, mimeType: "image/png", operation });
|
||||||
} else if (maskEdit?.maskLayerId === options.session.layerId) {
|
} else if (maskEdit?.maskLayerId === options.session.layerId) {
|
||||||
options.store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: maskEdit.maskLayerId, source, mimeType: "image/png", operation: { type: "paint" } });
|
options.store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: maskEdit.maskLayerId, source, mimeType: "image/png", operation });
|
||||||
|
} else if (isAttachedLayerMask(state.document, options.session.layerId)) {
|
||||||
|
options.store.dispatch(commandIds.documentApplyLayerMaskOperation, { maskLayerId: options.session.layerId, source, mimeType: "image/png", operation });
|
||||||
} else {
|
} else {
|
||||||
options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: options.session.assetId, source });
|
options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: options.session.assetId, source });
|
||||||
}
|
}
|
||||||
@@ -167,6 +248,33 @@ export async function commitBrushSession(options: { store: AppStore; session: Br
|
|||||||
closeBrushStrokePreview(options.session);
|
closeBrushStrokePreview(options.session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPaintTool(tool: ToolState["activeTool"]): tool is "brush" | "eraser" | "feather" {
|
||||||
|
return tool === "brush" || tool === "eraser" || tool === "feather";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSelectedFeatherLayer(document: ImageDocument, editor: BrushTargetEditorState) {
|
||||||
|
if (isPanInteractionMode(editor.tools.interactionMode)) return undefined;
|
||||||
|
const layerId = editor.selection.layerIds[0];
|
||||||
|
if (!layerId) return undefined;
|
||||||
|
const layer = findLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
|
||||||
|
return layer && (layer.type === "image" || layer.type === "raster") && layer.visible && !layer.locked ? layer : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAttachedLayerMask(document: ImageDocument, maskLayerId: string) {
|
||||||
|
const stack = document.artboards.flatMap((artboard) => artboard.layers);
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const layer = stack.pop();
|
||||||
|
if (!layer) continue;
|
||||||
|
if (getLayerMask(layer)?.maskLayerId === maskLayerId) return true;
|
||||||
|
if (layer.type === "group") stack.push(...layer.children);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function opaqueMaskSource(width: number, height: number) {
|
||||||
|
return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="white"/></svg>`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function cancelBrushSession(options: { store: AppStore; session: BrushSession }) {
|
export function cancelBrushSession(options: { store: AppStore; session: BrushSession }) {
|
||||||
options.session.cancelled = true;
|
options.session.cancelled = true;
|
||||||
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
|
options.store.dispatch(commandIds.toolSetBrushStrokePreview, undefined);
|
||||||
@@ -225,7 +333,13 @@ async function publishBrushStrokePreview(options: { store: AppStore; session: Br
|
|||||||
|
|
||||||
const previousSource = options.session.previewSource;
|
const previousSource = options.session.previewSource;
|
||||||
options.session.previewSource = source;
|
options.session.previewSource = source;
|
||||||
options.store.dispatch(commandIds.toolSetBrushStrokePreview, { layerId: options.session.layerId, assetId: options.session.assetId, source });
|
options.store.dispatch(commandIds.toolSetBrushStrokePreview, {
|
||||||
|
layerId: options.session.layerId,
|
||||||
|
assetId: options.session.assetId,
|
||||||
|
source,
|
||||||
|
pendingTargetLayerId: options.session.pendingLayerMask?.targetLayerId,
|
||||||
|
intrinsicSize: options.session.pendingLayerMask ? { w: options.session.width, h: options.session.height } : undefined,
|
||||||
|
});
|
||||||
releaseObjectUrl(previousSource);
|
releaseObjectUrl(previousSource);
|
||||||
|
|
||||||
if (options.session.previewRequested) requestBrushStrokePreview(options);
|
if (options.session.previewRequested) requestBrushStrokePreview(options);
|
||||||
@@ -243,11 +357,6 @@ function closeBrushStrokePreview(session: BrushSession) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function findRasterLayer(layers: Layer[], layerId: string): RasterLayer | undefined {
|
|
||||||
const layer = findLayer(layers, layerId);
|
|
||||||
return layer?.type === "raster" ? layer : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findLayer(layers: Layer[], layerId: string): Layer | undefined {
|
function findLayer(layers: Layer[], layerId: string): Layer | undefined {
|
||||||
for (const layer of layers) {
|
for (const layer of layers) {
|
||||||
if (layer.id === layerId) return layer;
|
if (layer.id === layerId) return layer;
|
||||||
|
|||||||
40
platform/browser/brushRaster.test.ts
Normal file
40
platform/browser/brushRaster.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { blendFeatherPatch } from "./brushRaster";
|
||||||
|
|
||||||
|
describe("feather brush raster blending", () => {
|
||||||
|
test("mixes blurred mask values inside the stroke and preserves pixels outside it", () => {
|
||||||
|
const original = rgbaRow([255, 255, 255, 255, 255]);
|
||||||
|
const blurred = rgbaRow([0, 64, 128, 192, 255]);
|
||||||
|
|
||||||
|
blendFeatherPatch(original, blurred, 5, 1, { x: 0, y: 0 }, {
|
||||||
|
from: { x: 2.5, y: 0.5 },
|
||||||
|
to: { x: 2.5, y: 0.5 },
|
||||||
|
size: 3,
|
||||||
|
strength: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(alphaValues(original)).toEqual([255, 64, 128, 192, 255]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applies strength as a non-destructive mix", () => {
|
||||||
|
const original = rgbaRow([255]);
|
||||||
|
const blurred = rgbaRow([0]);
|
||||||
|
|
||||||
|
blendFeatherPatch(original, blurred, 1, 1, { x: 0, y: 0 }, {
|
||||||
|
from: { x: 0.5, y: 0.5 },
|
||||||
|
to: { x: 0.5, y: 0.5 },
|
||||||
|
size: 10,
|
||||||
|
strength: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(alphaValues(original)).toEqual([128]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function rgbaRow(alpha: number[]) {
|
||||||
|
return new Uint8ClampedArray(alpha.flatMap((value) => [255, 255, 255, value]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function alphaValues(data: Uint8ClampedArray) {
|
||||||
|
return Array.from({ length: data.length / 4 }, (_, index) => data[index * 4 + 3]);
|
||||||
|
}
|
||||||
@@ -5,12 +5,26 @@ export type BrushSurface = {
|
|||||||
readonly resource: object;
|
readonly resource: object;
|
||||||
};
|
};
|
||||||
|
|
||||||
type InternalSurface = BrushSurface & { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D };
|
type InternalSurface = BrushSurface & {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
context: CanvasRenderingContext2D;
|
||||||
|
featherSource: HTMLCanvasElement;
|
||||||
|
featherBlur: HTMLCanvasElement;
|
||||||
|
};
|
||||||
|
|
||||||
export function createBrushSurface(width: number, height: number, source: string): BrushSurface | undefined {
|
export function createBrushSurface(width: number, height: number, source: string): BrushSurface | undefined {
|
||||||
const canvas = document.createElement("canvas"); canvas.width = Math.max(1, Math.round(width)); canvas.height = Math.max(1, Math.round(height));
|
const canvas = document.createElement("canvas"); canvas.width = Math.max(1, Math.round(width)); canvas.height = Math.max(1, Math.round(height));
|
||||||
const context = canvas.getContext("2d"); if (!context) return undefined;
|
const context = canvas.getContext("2d"); if (!context) return undefined;
|
||||||
const surface = { width: canvas.width, height: canvas.height, canvas, context, resource: {}, ready: Promise.resolve(false) } as InternalSurface;
|
const surface = {
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
canvas,
|
||||||
|
context,
|
||||||
|
featherSource: document.createElement("canvas"),
|
||||||
|
featherBlur: document.createElement("canvas"),
|
||||||
|
resource: {},
|
||||||
|
ready: Promise.resolve(false),
|
||||||
|
} as InternalSurface;
|
||||||
(surface as { ready: Promise<boolean> }).ready = loadImage(source).then((image) => { context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(image, 0, 0, canvas.width, canvas.height); return true; }).catch(() => false);
|
(surface as { ready: Promise<boolean> }).ready = loadImage(source).then((image) => { context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(image, 0, 0, canvas.width, canvas.height); return true; }).catch(() => false);
|
||||||
return surface;
|
return surface;
|
||||||
}
|
}
|
||||||
@@ -20,6 +34,74 @@ export function drawBrushSegment(surface: BrushSurface, options: { from: { x: nu
|
|||||||
context.save(); context.globalAlpha = Math.max(0, Math.min(1, options.opacity / 100)) * Math.max(0.01, Math.min(1, options.flow / 100)); context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over"; context.strokeStyle = options.color; context.shadowColor = options.mode === "eraser" ? "rgba(0,0,0,1)" : options.color; context.shadowBlur = (1 - hardness) * options.size; context.lineWidth = options.size; context.lineCap = "round"; context.lineJoin = "round"; context.beginPath(); context.moveTo(options.from.x, options.from.y); context.lineTo(options.to.x, options.to.y); context.stroke(); context.restore();
|
context.save(); context.globalAlpha = Math.max(0, Math.min(1, options.opacity / 100)) * Math.max(0.01, Math.min(1, options.flow / 100)); context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over"; context.strokeStyle = options.color; context.shadowColor = options.mode === "eraser" ? "rgba(0,0,0,1)" : options.color; context.shadowBlur = (1 - hardness) * options.size; context.lineWidth = options.size; context.lineCap = "round"; context.lineJoin = "round"; context.beginPath(); context.moveTo(options.from.x, options.from.y); context.lineTo(options.to.x, options.to.y); context.stroke(); context.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function drawFeatherSegment(surface: BrushSurface, options: {
|
||||||
|
from: { x: number; y: number };
|
||||||
|
to: { x: number; y: number };
|
||||||
|
size: number;
|
||||||
|
radius: number;
|
||||||
|
strength: number;
|
||||||
|
}) {
|
||||||
|
const target = internal(surface);
|
||||||
|
const brushRadius = Math.max(0.5, options.size / 2);
|
||||||
|
const blurRadius = Math.max(1, Math.round(options.radius));
|
||||||
|
const padding = Math.ceil(blurRadius * 2.5);
|
||||||
|
const x1 = Math.max(0, Math.floor(Math.min(options.from.x, options.to.x) - brushRadius - padding));
|
||||||
|
const y1 = Math.max(0, Math.floor(Math.min(options.from.y, options.to.y) - brushRadius - padding));
|
||||||
|
const x2 = Math.min(target.width, Math.ceil(Math.max(options.from.x, options.to.x) + brushRadius + padding));
|
||||||
|
const y2 = Math.min(target.height, Math.ceil(Math.max(options.from.y, options.to.y) + brushRadius + padding));
|
||||||
|
const width = x2 - x1;
|
||||||
|
const height = y2 - y1;
|
||||||
|
if (width <= 0 || height <= 0) return;
|
||||||
|
|
||||||
|
const scratchWidth = width + padding * 2;
|
||||||
|
const scratchHeight = height + padding * 2;
|
||||||
|
resizeCanvas(target.featherSource, scratchWidth, scratchHeight);
|
||||||
|
resizeCanvas(target.featherBlur, scratchWidth, scratchHeight);
|
||||||
|
const sourceContext = target.featherSource.getContext("2d");
|
||||||
|
const blurContext = target.featherBlur.getContext("2d");
|
||||||
|
if (!sourceContext || !blurContext) return;
|
||||||
|
|
||||||
|
sourceContext.clearRect(0, 0, scratchWidth, scratchHeight);
|
||||||
|
sourceContext.drawImage(target.canvas, x1, y1, width, height, padding, padding, width, height);
|
||||||
|
blurContext.clearRect(0, 0, scratchWidth, scratchHeight);
|
||||||
|
blurContext.save();
|
||||||
|
blurContext.filter = `blur(${blurRadius}px)`;
|
||||||
|
blurContext.drawImage(target.featherSource, 0, 0);
|
||||||
|
blurContext.restore();
|
||||||
|
|
||||||
|
const original = target.context.getImageData(x1, y1, width, height);
|
||||||
|
const blurred = blurContext.getImageData(padding, padding, width, height);
|
||||||
|
blendFeatherPatch(original.data, blurred.data, width, height, { x: x1, y: y1 }, options);
|
||||||
|
target.context.putImageData(original, x1, y1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blendFeatherPatch(
|
||||||
|
original: Uint8ClampedArray,
|
||||||
|
blurred: Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
origin: { x: number; y: number },
|
||||||
|
options: { from: { x: number; y: number }; to: { x: number; y: number }; size: number; strength: number },
|
||||||
|
) {
|
||||||
|
const brushRadius = Math.max(0.5, options.size / 2);
|
||||||
|
const strength = Math.max(0.01, Math.min(1, options.strength / 100));
|
||||||
|
for (let y = 0; y < height; y += 1) {
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
const distance = distanceToSegment(origin.x + x + 0.5, origin.y + y + 0.5, options.from, options.to);
|
||||||
|
if (distance >= brushRadius) continue;
|
||||||
|
const normalized = distance / brushRadius;
|
||||||
|
const coverage = 1 - smoothstep(0.72, 1, normalized);
|
||||||
|
const mix = coverage * strength;
|
||||||
|
const index = (y * width + x) * 4;
|
||||||
|
for (let channel = 0; channel < 4; channel += 1) {
|
||||||
|
const current = original[index + channel] ?? 0;
|
||||||
|
const feathered = blurred[index + channel] ?? 0;
|
||||||
|
original[index + channel] = Math.round(current + (feathered - current) * mix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function brushSurfaceDataUrl(surface: BrushSurface) { try { return internal(surface).canvas.toDataURL("image/png"); } catch { return undefined; } }
|
export function brushSurfaceDataUrl(surface: BrushSurface) { try { return internal(surface).canvas.toDataURL("image/png"); } catch { return undefined; } }
|
||||||
export function brushSurfaceObjectUrl(surface: BrushSurface) { return new Promise<string | undefined>((resolve) => internal(surface).canvas.toBlob((blob) => resolve(blob ? URL.createObjectURL(blob) : undefined), "image/png")); }
|
export function brushSurfaceObjectUrl(surface: BrushSurface) { return new Promise<string | undefined>((resolve) => internal(surface).canvas.toBlob((blob) => resolve(blob ? URL.createObjectURL(blob) : undefined), "image/png")); }
|
||||||
export function releaseObjectUrl(source?: string) { if (source) URL.revokeObjectURL(source); }
|
export function releaseObjectUrl(source?: string) { if (source) URL.revokeObjectURL(source); }
|
||||||
@@ -27,3 +109,22 @@ export function scheduleFrame(callback: () => void) { return requestAnimationFra
|
|||||||
export function cancelFrame(id: number) { cancelAnimationFrame(id); }
|
export function cancelFrame(id: number) { cancelAnimationFrame(id); }
|
||||||
function internal(surface: BrushSurface) { return surface as InternalSurface; }
|
function internal(surface: BrushSurface) { return surface as InternalSurface; }
|
||||||
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 raster layer")); image.src = source; }); }
|
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 raster layer")); image.src = source; }); }
|
||||||
|
|
||||||
|
function resizeCanvas(canvas: HTMLCanvasElement, width: number, height: number) {
|
||||||
|
if (canvas.width !== width) canvas.width = width;
|
||||||
|
if (canvas.height !== height) canvas.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceToSegment(x: number, y: number, from: { x: number; y: number }, to: { x: number; y: number }) {
|
||||||
|
const dx = to.x - from.x;
|
||||||
|
const dy = to.y - from.y;
|
||||||
|
const lengthSquared = dx * dx + dy * dy;
|
||||||
|
if (lengthSquared <= 0.0001) return Math.hypot(x - from.x, y - from.y);
|
||||||
|
const amount = Math.max(0, Math.min(1, ((x - from.x) * dx + (y - from.y) * dy) / lengthSquared));
|
||||||
|
return Math.hypot(x - (from.x + dx * amount), y - (from.y + dy * amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothstep(edge0: number, edge1: number, value: number) {
|
||||||
|
const amount = Math.max(0, Math.min(1, (value - edge0) / Math.max(0.0001, edge1 - edge0)));
|
||||||
|
return amount * amount * (3 - 2 * amount);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { Vec2D } from "@core/geometry";
|
import type { Vec2D } from "@core/geometry";
|
||||||
import type { Layer } from "@core/layer";
|
import type { Layer } from "@core/layer";
|
||||||
|
import { getLayerMask } from "@core/layer-mask-utils";
|
||||||
import type { EditorState } from "@editor/state";
|
import type { EditorState } from "@editor/state";
|
||||||
import type { RgbaColor, WebGlRendererContext } from "./types";
|
import type { RgbaColor, WebGlRendererContext } from "./types";
|
||||||
|
|
||||||
@@ -78,13 +79,13 @@ function drawPreviewPass(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveBrushPreview(document: ImageDocument, editor: EditorState, canvas: HTMLCanvasElement) {
|
function resolveBrushPreview(document: ImageDocument, editor: EditorState, canvas: HTMLCanvasElement) {
|
||||||
if (!editor.brushPreview || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
|
if (!editor.brushPreview || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser" && editor.tools.activeTool !== "feather")) return undefined;
|
||||||
if (editor.tools.interactionMode.type === "temporary-pan" || (editor.tools.interactionMode.type === "tool" && editor.tools.interactionMode.tool === "pan")) return undefined;
|
if (editor.tools.interactionMode.type === "temporary-pan" || (editor.tools.interactionMode.type === "tool" && editor.tools.interactionMode.tool === "pan")) return undefined;
|
||||||
|
|
||||||
const layer = resolveBrushTargetLayer(document, editor);
|
const layer = resolveBrushTargetLayer(document, editor);
|
||||||
if (!layer) return undefined;
|
if (!layer) return undefined;
|
||||||
|
|
||||||
const size = Math.max(1, editor.tools.brush.size);
|
const size = Math.max(1, editor.tools.activeTool === "feather" ? editor.tools.feather.size : editor.tools.brush.size);
|
||||||
const zoom = editor.viewport.zoom;
|
const zoom = editor.viewport.zoom;
|
||||||
return {
|
return {
|
||||||
center: documentPointToScreenPoint(canvas, editor.brushPreview.position, editor),
|
center: documentPointToScreenPoint(canvas, editor.brushPreview.position, editor),
|
||||||
@@ -92,7 +93,7 @@ function resolveBrushPreview(document: ImageDocument, editor: EditorState, canva
|
|||||||
x: Math.max(1, Math.abs(layer.transform.scale.x) * size * zoom * 0.5),
|
x: Math.max(1, Math.abs(layer.transform.scale.x) * size * zoom * 0.5),
|
||||||
y: Math.max(1, Math.abs(layer.transform.scale.y) * size * zoom * 0.5),
|
y: Math.max(1, Math.abs(layer.transform.scale.y) * size * zoom * 0.5),
|
||||||
},
|
},
|
||||||
hardness: Math.max(0, Math.min(1, editor.tools.brush.hardness / 100)),
|
hardness: editor.tools.activeTool === "feather" ? 0.72 : Math.max(0, Math.min(1, editor.tools.brush.hardness / 100)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +102,10 @@ function resolveBrushTargetLayer(document: ImageDocument, editor: EditorState):
|
|||||||
const layerId = editor.maskEdit?.kind === "inpaintRegion" ? editor.maskEdit.targetLayerId : editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
|
const layerId = editor.maskEdit?.kind === "inpaintRegion" ? editor.maskEdit.targetLayerId : editor.maskEdit?.maskLayerId ?? editor.selection.layerIds[0];
|
||||||
if (!layerId) return undefined;
|
if (!layerId) return undefined;
|
||||||
|
|
||||||
const layer = findPaintableLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
|
const layers = document.artboards.flatMap((artboard) => artboard.layers);
|
||||||
|
const selectedLayer = findPaintableLayer(layers, layerId);
|
||||||
|
const maskLayerId = editor.tools.activeTool === "feather" && !editingMask && selectedLayer ? getLayerMask(selectedLayer)?.maskLayerId : undefined;
|
||||||
|
const layer = maskLayerId ? findPaintableLayer(layers, maskLayerId) : selectedLayer;
|
||||||
if (!layer || layer.locked || (!editingMask && !layer.visible)) return undefined;
|
if (!layer || layer.locked || (!editingMask && !layer.visible)) return undefined;
|
||||||
return layer;
|
return layer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,10 +120,19 @@ function renderLeafLayer(
|
|||||||
}
|
}
|
||||||
const layerMask = getLayerMask(layer);
|
const layerMask = getLayerMask(layer);
|
||||||
const maskLayer = !editingMaskLayer && layerMask?.enabled ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
|
const maskLayer = !editingMaskLayer && layerMask?.enabled ? documentIndex.layerById.get(layerMask.maskLayerId) : undefined;
|
||||||
const maskAsset = assetWithBrushStrokePreview(maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor);
|
const pendingMaskPreview = editor.brushStrokePreview?.pendingTargetLayerId === layer.id && editor.brushStrokePreview.intrinsicSize
|
||||||
|
? {
|
||||||
|
id: editor.brushStrokePreview.assetId,
|
||||||
|
name: `${layer.name} Mask Preview`,
|
||||||
|
mimeType: "image/png",
|
||||||
|
source: editor.brushStrokePreview.source,
|
||||||
|
intrinsicSize: editor.brushStrokePreview.intrinsicSize,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const maskAsset = assetWithBrushStrokePreview(maskLayer && (maskLayer.type === "image" || maskLayer.type === "raster") ? documentIndex.assetById.get(maskLayer.assetId) : undefined, editor) ?? pendingMaskPreview;
|
||||||
const maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : undefined;
|
const maskBounds = maskLayer ? resolveIndexedLayerBounds(documentIndex, maskLayer) : undefined;
|
||||||
const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : undefined;
|
const maskRect = maskBounds ? documentRectToScreenRect(context.canvas, maskBounds, editor.viewport) : pendingMaskPreview ? rect : undefined;
|
||||||
const activeMaskTarget = Boolean(editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layerMask?.maskLayerId);
|
const activeMaskTarget = Boolean((editor.maskEdit?.targetLayerId === layer.id && editor.maskEdit.maskLayerId === layerMask?.maskLayerId) || pendingMaskPreview);
|
||||||
const showMaskRevealPreview = editor.tools.activeTool === "brush" && activeMaskTarget && maskViewMode === "composite";
|
const showMaskRevealPreview = editor.tools.activeTool === "brush" && activeMaskTarget && maskViewMode === "composite";
|
||||||
|
|
||||||
if (asset && maskAsset && maskRect && activeMaskTarget) {
|
if (asset && maskAsset && maskRect && activeMaskTarget) {
|
||||||
|
|||||||
@@ -220,11 +220,12 @@ export function App({ app }: AppProps) {
|
|||||||
document={document}
|
document={document}
|
||||||
selection={selection}
|
selection={selection}
|
||||||
viewport={viewport}
|
viewport={viewport}
|
||||||
visible={generateOpen || chromaKeyOpen || tools.activeTool === "brush" || tools.activeTool === "eraser" || tools.activeTool === "magicWand" || tools.activeTool === "semanticSelect" || tools.activeTool === "maskLasso" || tools.activeTool === "maskRectangle" || Boolean(transformBounds) || viewportActivityIsland.visible}
|
visible={generateOpen || chromaKeyOpen || tools.activeTool === "brush" || tools.activeTool === "eraser" || tools.activeTool === "feather" || tools.activeTool === "magicWand" || tools.activeTool === "semanticSelect" || tools.activeTool === "maskLasso" || tools.activeTool === "maskRectangle" || Boolean(transformBounds) || viewportActivityIsland.visible}
|
||||||
action={viewportActivityIsland.action}
|
action={viewportActivityIsland.action}
|
||||||
activeTool={tools.activeTool}
|
activeTool={tools.activeTool}
|
||||||
operation={generateOpen ? "generate" : chromaKeyOpen ? "chromaKey" : undefined}
|
operation={generateOpen ? "generate" : chromaKeyOpen ? "chromaKey" : undefined}
|
||||||
brushSettings={tools.brush}
|
brushSettings={tools.brush}
|
||||||
|
featherSettings={tools.feather}
|
||||||
generateSettings={tools.generate}
|
generateSettings={tools.generate}
|
||||||
generation={generation}
|
generation={generation}
|
||||||
chromaKeySettings={tools.chromaKey}
|
chromaKeySettings={tools.chromaKey}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { useMemo } from "react";
|
|||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import type { ImageDocument } from "@core/document";
|
import type { ImageDocument } from "@core/document";
|
||||||
import type { GenerationState, MaskViewMode, SelectionState, ViewportState } from "@editor/state";
|
import type { GenerationState, MaskViewMode, SelectionState, ViewportState } from "@editor/state";
|
||||||
import type { BrushSettings, ChromaKeySettings, GenerateSettings, MagicWandSettings, OperationId, ToolId } from "@editor/tools";
|
import type { BrushSettings, ChromaKeySettings, FeatherSettings, GenerateSettings, MagicWandSettings, OperationId, ToolId } from "@editor/tools";
|
||||||
import { BrushControls } from "./bottom-controls/BrushControls";
|
import { BrushControls } from "./bottom-controls/BrushControls";
|
||||||
|
import { FeatherControls } from "./bottom-controls/FeatherControls";
|
||||||
import { ChromaKeyControls } from "./bottom-controls/ChromaKeyControls";
|
import { ChromaKeyControls } from "./bottom-controls/ChromaKeyControls";
|
||||||
import { MagicWandControls } from "./bottom-controls/MagicWandControls";
|
import { MagicWandControls } from "./bottom-controls/MagicWandControls";
|
||||||
import { GenerateActionControls } from "./bottom-controls/GenerateActionControls";
|
import { GenerateActionControls } from "./bottom-controls/GenerateActionControls";
|
||||||
@@ -26,6 +27,7 @@ export type BottomControlsIslandProps = {
|
|||||||
activeTool: ToolId;
|
activeTool: ToolId;
|
||||||
operation?: OperationId;
|
operation?: OperationId;
|
||||||
brushSettings: BrushSettings;
|
brushSettings: BrushSettings;
|
||||||
|
featherSettings: FeatherSettings;
|
||||||
generateSettings: GenerateSettings;
|
generateSettings: GenerateSettings;
|
||||||
generation: GenerationState;
|
generation: GenerationState;
|
||||||
chromaKeySettings: ChromaKeySettings;
|
chromaKeySettings: ChromaKeySettings;
|
||||||
@@ -41,7 +43,7 @@ export type BottomControlsIslandProps = {
|
|||||||
documentActions: DocumentActions;
|
documentActions: DocumentActions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, operation, brushSettings, generateSettings, generation, chromaKeySettings, magicWandSettings, editingMask = false, maskKind, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch, generationWorkflow, documentActions }: BottomControlsIslandProps) {
|
export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, operation, brushSettings, featherSettings, generateSettings, generation, chromaKeySettings, magicWandSettings, editingMask = false, maskKind, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch, generationWorkflow, documentActions }: BottomControlsIslandProps) {
|
||||||
const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]);
|
const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]);
|
||||||
const selectedLayerInfo = selection.layerIds.length === 1 && selection.layerIds[0] ? documentIndex.layerInfoById.get(selection.layerIds[0]) : undefined;
|
const selectedLayerInfo = selection.layerIds.length === 1 && selection.layerIds[0] ? documentIndex.layerInfoById.get(selection.layerIds[0]) : undefined;
|
||||||
const zoomPercent = Math.round(viewport.zoom * 100);
|
const zoomPercent = Math.round(viewport.zoom * 100);
|
||||||
@@ -59,10 +61,12 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
|
|||||||
<GenerateActionControls settings={generateSettings} generation={generation} dispatch={dispatch} workflow={generationWorkflow} generate={documentActions.generate} />
|
<GenerateActionControls settings={generateSettings} generation={generation} dispatch={dispatch} workflow={generationWorkflow} generate={documentActions.generate} />
|
||||||
) : operation === "chromaKey" ? (
|
) : operation === "chromaKey" ? (
|
||||||
<ChromaKeyControls document={document} selection={selection} settings={chromaKeySettings} dispatch={dispatch} />
|
<ChromaKeyControls document={document} selection={selection} settings={chromaKeySettings} dispatch={dispatch} />
|
||||||
) : (activeTool === "brush" || activeTool === "eraser") && brushHint ? (
|
) : (activeTool === "brush" || activeTool === "eraser" || activeTool === "feather") && brushHint ? (
|
||||||
<BrushHint tool={activeTool} hint={brushHint} />
|
<BrushHint tool={activeTool} hint={brushHint} />
|
||||||
) : activeTool === "brush" || activeTool === "eraser" ? (
|
) : activeTool === "brush" || activeTool === "eraser" ? (
|
||||||
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskKind={maskKind} maskViewMode={maskViewMode} dispatch={dispatch} />
|
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskKind={maskKind} maskViewMode={maskViewMode} dispatch={dispatch} />
|
||||||
|
) : activeTool === "feather" ? (
|
||||||
|
<FeatherControls settings={featherSettings} editingMask={editingMask} dispatch={dispatch} />
|
||||||
) : activeTool === "magicWand" ? (
|
) : activeTool === "magicWand" ? (
|
||||||
<MagicWandControls settings={magicWandSettings} dispatch={dispatch} />
|
<MagicWandControls settings={magicWandSettings} dispatch={dispatch} />
|
||||||
) : activeTool === "semanticSelect" ? (
|
) : activeTool === "semanticSelect" ? (
|
||||||
@@ -80,7 +84,7 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BrushHint({ tool, hint }: { tool: "brush" | "eraser"; hint: string }) {
|
function BrushHint({ tool, hint }: { tool: "brush" | "eraser" | "feather"; hint: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 px-4 text-center">
|
<div className="flex items-center gap-3 px-4 text-center">
|
||||||
<span className="rounded bg-white/[0.06] px-2 py-0.5 text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-white/45">{tool}</span>
|
<span className="rounded bg-white/[0.06] px-2 py-0.5 text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-white/45">{tool}</span>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Cursor, Eraser, Hand, PaintBrush, DropHalf, MagicWand, Sparkle, Polygon, Rectangle, Selection } from "@phosphor-icons/react";
|
import { Cursor, Eraser, Feather, Hand, PaintBrush, DropHalf, MagicWand, Sparkle, Polygon, Rectangle, Selection } from "@phosphor-icons/react";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import type { InteractionMode, OperationId, ToolId } from "@editor/tools";
|
import type { InteractionMode, OperationId, ToolId } from "@editor/tools";
|
||||||
@@ -66,6 +66,8 @@ function iconForTool(tool: ToolId) {
|
|||||||
return PaintBrush;
|
return PaintBrush;
|
||||||
case "eraser":
|
case "eraser":
|
||||||
return Eraser;
|
return Eraser;
|
||||||
|
case "feather":
|
||||||
|
return Feather;
|
||||||
case "magicWand":
|
case "magicWand":
|
||||||
return MagicWand;
|
return MagicWand;
|
||||||
case "semanticSelect":
|
case "semanticSelect":
|
||||||
|
|||||||
46
view/bottom-controls/FeatherControls.tsx
Normal file
46
view/bottom-controls/FeatherControls.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Feather } from "@phosphor-icons/react";
|
||||||
|
import { commandIds } from "@commands/ids";
|
||||||
|
import type { AppStore } from "@editor/store";
|
||||||
|
import type { FeatherSettings } from "@editor/tools";
|
||||||
|
import { BottomControlDivider } from "./Divider";
|
||||||
|
import { BottomControlSlider } from "./Slider";
|
||||||
|
import { bottomControlFieldClass, bottomControlIconSlotClass, bottomControlLabelClass, bottomControlMenuClass } from "./styles";
|
||||||
|
|
||||||
|
export function FeatherControls({ settings, editingMask, dispatch }: { settings: FeatherSettings; editingMask: boolean; dispatch: AppStore["dispatch"] }) {
|
||||||
|
return (
|
||||||
|
<div className={bottomControlMenuClass()}>
|
||||||
|
<span className={bottomControlIconSlotClass()} title="Feather mask edges"><Feather size={24} weight="regular" /></span>
|
||||||
|
<BottomControlDivider />
|
||||||
|
<span className="whitespace-nowrap text-xs font-medium text-white/65">{editingMask ? "Feather mask" : "Feather layer mask"}</span>
|
||||||
|
<BottomControlDivider />
|
||||||
|
<FeatherSlider label="Size" min={1} max={400} value={settings.size} onValueChange={(size) => dispatch(commandIds.toolSetFeatherSettings, { size })} />
|
||||||
|
<BottomControlDivider />
|
||||||
|
<FeatherSlider label="Radius" min={1} max={128} value={settings.radius} onValueChange={(radius) => dispatch(commandIds.toolSetFeatherSettings, { radius })} />
|
||||||
|
<BottomControlDivider />
|
||||||
|
<FeatherSlider label="Strength" min={1} max={100} value={settings.strength} suffix="%" onValueChange={(strength) => dispatch(commandIds.toolSetFeatherSettings, { strength })} />
|
||||||
|
<BottomControlDivider />
|
||||||
|
<FeatherSlider label="Smooth" min={0} max={100} value={settings.smoothing} suffix="%" onValueChange={(smoothing) => dispatch(commandIds.toolSetFeatherSettings, { smoothing })} />
|
||||||
|
<button type="button" className={toggleClass(settings.pressureSize)} aria-pressed={settings.pressureSize} title="Use pen pressure for feather brush size" onClick={() => dispatch(commandIds.toolSetFeatherSettings, { pressureSize: !settings.pressureSize })}>Pressure</button>
|
||||||
|
{editingMask ? (
|
||||||
|
<>
|
||||||
|
<BottomControlDivider />
|
||||||
|
<button type="button" className="rounded-md bg-sky-300 px-3 py-1.5 text-xs font-semibold text-slate-950 transition hover:bg-sky-200" onClick={() => dispatch(commandIds.toolExitMaskEdit, undefined)}>Done</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeatherSlider({ label, min, max, value, suffix = "", onValueChange }: { label: string; min: number; max: number; value: number; suffix?: string; onValueChange: (value: number) => void }) {
|
||||||
|
return (
|
||||||
|
<label className={bottomControlFieldClass()}>
|
||||||
|
<span className={bottomControlLabelClass()}>{label}</span>
|
||||||
|
<BottomControlSlider min={min} max={max} value={value} className="min-w-10 w-[clamp(2.5rem,8vw,6.5rem)] shrink" aria-label={`Feather ${label.toLowerCase()}`} onValueChange={onValueChange} />
|
||||||
|
<span className="w-10 text-right text-xs font-medium text-white/85">{Math.round(value)}{suffix}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleClass(active: boolean) {
|
||||||
|
return `rounded-md px-3 py-1.5 text-xs font-medium transition ${active ? "bg-sky-300 text-slate-950" : "bg-white/[0.06] text-white/70 hover:bg-white/10"}`;
|
||||||
|
}
|
||||||
@@ -9,4 +9,8 @@ describe("canvas cursor", () => {
|
|||||||
test("uses the default cursor while an operation suspends canvas tools", () => {
|
test("uses the default cursor while an operation suspends canvas tools", () => {
|
||||||
expect(canvasCursorClass({ type: "tool", tool: "brush" }, false, false, true, true)).toBe("cursor-default");
|
expect(canvasCursorClass({ type: "tool", tool: "brush" }, false, false, true, true)).toBe("cursor-default");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses the live brush cursor for feathering", () => {
|
||||||
|
expect(canvasCursorClass({ type: "tool", tool: "feather" }, false, true, true, false)).toBe("cursor-none");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export function canvasCursorClass(interactionMode: InteractionMode, isPanning: b
|
|||||||
if (operationOpen) return "cursor-default";
|
if (operationOpen) return "cursor-default";
|
||||||
if (isPanning) return "cursor-grabbing";
|
if (isPanning) return "cursor-grabbing";
|
||||||
if (isPanInteractionMode(interactionMode)) return "cursor-grab";
|
if (isPanInteractionMode(interactionMode)) return "cursor-grab";
|
||||||
if (interactionMode.type === "tool" && (interactionMode.tool === "brush" || interactionMode.tool === "eraser")) {
|
if (interactionMode.type === "tool" && (interactionMode.tool === "brush" || interactionMode.tool === "eraser" || interactionMode.tool === "feather")) {
|
||||||
if (!canBrush) return "cursor-not-allowed";
|
if (!canBrush) return "cursor-not-allowed";
|
||||||
return hasBrushPreview ? "cursor-none" : "cursor-crosshair";
|
return hasBrushPreview ? "cursor-none" : "cursor-crosshair";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,9 +78,20 @@ describe("canvas render frame selection", () => {
|
|||||||
brushPreview: { position: { x: 10, y: 20 } },
|
brushPreview: { position: { x: 10, y: 20 } },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const changedFeather = {
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
tools: {
|
||||||
|
...state.editor.tools,
|
||||||
|
feather: { ...state.editor.tools.feather, size: state.editor.tools.feather.size + 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
expect(canvasRenderFramesEqual(selectCanvasRenderFrame(state), selectCanvasRenderFrame(sameSelectionValues))).toBe(true);
|
expect(canvasRenderFramesEqual(selectCanvasRenderFrame(state), selectCanvasRenderFrame(sameSelectionValues))).toBe(true);
|
||||||
expectFrameChanged(state, changedBrush);
|
expectFrameChanged(state, changedBrush);
|
||||||
|
expectFrameChanged(state, changedFeather);
|
||||||
expectFrameChanged(state, changedPreview);
|
expectFrameChanged(state, changedPreview);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Rect, Vec2D } from "@core/geometry";
|
import type { Rect, Vec2D } from "@core/geometry";
|
||||||
import type { RenderFrame } from "@renderer/index";
|
import type { RenderFrame } from "@renderer/index";
|
||||||
import type { AppState, BrushPreviewState, BrushStrokePreviewState, EditorState, GenerationState, MaskEditState, SelectionState, ViewportState } from "@editor/state";
|
import type { AppState, BrushPreviewState, BrushStrokePreviewState, EditorState, GenerationState, MaskEditState, SelectionState, ViewportState } from "@editor/state";
|
||||||
import type { BrushSettings, InteractionMode } from "@editor/tools";
|
import type { BrushSettings, FeatherSettings, InteractionMode } from "@editor/tools";
|
||||||
import type { TransformSession, TransformTarget } from "@editor/transform";
|
import type { TransformSession, TransformTarget } from "@editor/transform";
|
||||||
|
|
||||||
export function selectCanvasRenderFrame(state: AppState): RenderFrame {
|
export function selectCanvasRenderFrame(state: AppState): RenderFrame {
|
||||||
@@ -63,7 +63,12 @@ function brushPreviewStatesEqual(a: BrushPreviewState | undefined, b: BrushPrevi
|
|||||||
function brushStrokePreviewStatesEqual(a: BrushStrokePreviewState | undefined, b: BrushStrokePreviewState | undefined): boolean {
|
function brushStrokePreviewStatesEqual(a: BrushStrokePreviewState | undefined, b: BrushStrokePreviewState | undefined): boolean {
|
||||||
if (a === b) return true;
|
if (a === b) return true;
|
||||||
if (!a || !b) return false;
|
if (!a || !b) return false;
|
||||||
return a.layerId === b.layerId && a.assetId === b.assetId && a.source === b.source;
|
return a.layerId === b.layerId && a.assetId === b.assetId && a.source === b.source && a.pendingTargetLayerId === b.pendingTargetLayerId && sizesEqual(a.intrinsicSize, b.intrinsicSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sizesEqual(a: { w: number; h: number } | undefined, b: { w: number; h: number } | undefined) {
|
||||||
|
if (a === b) return true;
|
||||||
|
return Boolean(a && b && a.w === b.w && a.h === b.h);
|
||||||
}
|
}
|
||||||
|
|
||||||
function generationStatesEqual(a: GenerationState, b: GenerationState): boolean {
|
function generationStatesEqual(a: GenerationState, b: GenerationState): boolean {
|
||||||
@@ -71,7 +76,7 @@ function generationStatesEqual(a: GenerationState, b: GenerationState): boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
function visualToolStatesEqual(a: EditorState["tools"], b: EditorState["tools"]): boolean {
|
function visualToolStatesEqual(a: EditorState["tools"], b: EditorState["tools"]): boolean {
|
||||||
return a.activeTool === b.activeTool && interactionModesEqual(a.interactionMode, b.interactionMode) && brushSettingsEqual(a.brush, b.brush);
|
return a.activeTool === b.activeTool && interactionModesEqual(a.interactionMode, b.interactionMode) && brushSettingsEqual(a.brush, b.brush) && featherSettingsEqual(a.feather, b.feather);
|
||||||
}
|
}
|
||||||
|
|
||||||
function interactionModesEqual(a: InteractionMode, b: InteractionMode): boolean {
|
function interactionModesEqual(a: InteractionMode, b: InteractionMode): boolean {
|
||||||
@@ -84,6 +89,10 @@ function brushSettingsEqual(a: BrushSettings, b: BrushSettings): boolean {
|
|||||||
return a.color === b.color && a.size === b.size && a.hardness === b.hardness && a.opacity === b.opacity && a.flow === b.flow && a.smoothing === b.smoothing && a.pressureSize === b.pressureSize;
|
return a.color === b.color && a.size === b.size && a.hardness === b.hardness && a.opacity === b.opacity && a.flow === b.flow && a.smoothing === b.smoothing && a.pressureSize === b.pressureSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function featherSettingsEqual(a: FeatherSettings, b: FeatherSettings): boolean {
|
||||||
|
return a.size === b.size && a.radius === b.radius && a.strength === b.strength && a.smoothing === b.smoothing && a.pressureSize === b.pressureSize;
|
||||||
|
}
|
||||||
|
|
||||||
function vec2Equal(a: Vec2D, b: Vec2D): boolean {
|
function vec2Equal(a: Vec2D, b: Vec2D): boolean {
|
||||||
return a.x === b.x && a.y === b.y;
|
return a.x === b.x && a.y === b.y;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,8 +181,14 @@ export function useCanvasInput(
|
|||||||
|
|
||||||
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
|
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
|
||||||
store.dispatch(commandIds.toolSetBrushPreview, { position: point });
|
store.dispatch(commandIds.toolSetBrushPreview, { position: point });
|
||||||
const settings = store.getState().editor.tools.brush;
|
const tools = store.getState().editor.tools;
|
||||||
brushSession.current = updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness, opacity: settings.opacity, flow: settings.flow, smoothing: settings.smoothing, pressure: inputEvent.pressure ?? 1, pressureSize: settings.pressureSize });
|
if (brushSession.current.mode === "feather") {
|
||||||
|
const settings = tools.feather;
|
||||||
|
brushSession.current = updateBrushSession({ store, session: brushSession.current, point, color: "#ffffff", size: settings.size, hardness: 0, opacity: 100, flow: 100, smoothing: settings.smoothing, pressure: inputEvent.pressure ?? 1, pressureSize: settings.pressureSize, featherRadius: settings.radius, featherStrength: settings.strength });
|
||||||
|
} else {
|
||||||
|
const settings = tools.brush;
|
||||||
|
brushSession.current = updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness, opacity: settings.opacity, flow: settings.flow, smoothing: settings.smoothing, pressure: inputEvent.pressure ?? 1, pressureSize: settings.pressureSize });
|
||||||
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowCounterClockwise, ArrowDown, ArrowUp, CornersOut, Cursor, DownloadSimple, DropHalf, Eraser, Eye, EyeSlash, FolderOpen, FolderPlus, Hand, Lock, LockOpen, MagicWand, Minus, PaintBrush, Plus, Sparkle, Stack, Trash } from "@phosphor-icons/react";
|
import { ArrowCounterClockwise, ArrowDown, ArrowUp, CornersOut, Cursor, DownloadSimple, DropHalf, Eraser, Eye, EyeSlash, Feather, FolderOpen, FolderPlus, Hand, Lock, LockOpen, MagicWand, Minus, PaintBrush, Plus, Sparkle, Stack, Trash } from "@phosphor-icons/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { commandIds } from "@commands/ids";
|
import { commandIds } from "@commands/ids";
|
||||||
import type { Artboard } from "@core/artboard";
|
import type { Artboard } from "@core/artboard";
|
||||||
@@ -343,6 +343,8 @@ function toolIcon(tool: ToolId) {
|
|||||||
return <PaintBrush size={20} />;
|
return <PaintBrush size={20} />;
|
||||||
case "eraser":
|
case "eraser":
|
||||||
return <Eraser size={20} />;
|
return <Eraser size={20} />;
|
||||||
|
case "feather":
|
||||||
|
return <Feather size={20} />;
|
||||||
case "magicWand":
|
case "magicWand":
|
||||||
return <MagicWand size={20} />;
|
return <MagicWand size={20} />;
|
||||||
case "semanticSelect":
|
case "semanticSelect":
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export function labelForTool(tool: ToolId): string {
|
|||||||
return "AI region rectangle";
|
return "AI region rectangle";
|
||||||
case "eraser":
|
case "eraser":
|
||||||
return "Eraser";
|
return "Eraser";
|
||||||
|
case "feather":
|
||||||
|
return "Feather";
|
||||||
case "pan":
|
case "pan":
|
||||||
return "Pan";
|
return "Pan";
|
||||||
case "select":
|
case "select":
|
||||||
|
|||||||
Reference in New Issue
Block a user