feat(tools): add brush controls
This commit is contained in:
@@ -22,6 +22,7 @@ export const commandIds = {
|
|||||||
selectionClear: "selection.clear",
|
selectionClear: "selection.clear",
|
||||||
selectionAddLayer: "selection.addLayer",
|
selectionAddLayer: "selection.addLayer",
|
||||||
toolSetActive: "tool.setActive",
|
toolSetActive: "tool.setActive",
|
||||||
|
toolSetBrushSettings: "tool.setBrushSettings",
|
||||||
toolEnterTemporaryPan: "tool.enterTemporaryPan",
|
toolEnterTemporaryPan: "tool.enterTemporaryPan",
|
||||||
toolExitTemporaryPan: "tool.exitTemporaryPan",
|
toolExitTemporaryPan: "tool.exitTemporaryPan",
|
||||||
transformBegin: "transform.begin",
|
transformBegin: "transform.begin",
|
||||||
|
|||||||
@@ -50,10 +50,10 @@ export type { CommandRegistry } from "./registry";
|
|||||||
export { createCommandRegistry } from "./registry";
|
export { createCommandRegistry } from "./registry";
|
||||||
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
|
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
|
||||||
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||||
export { toolCommands, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand } from "./tool";
|
export { toolCommands, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushSettingsCommand } from "./tool";
|
||||||
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
|
export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform";
|
||||||
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||||
export type { ToolSetActivePayload } from "./tool";
|
export type { ToolSetActivePayload, ToolSetBrushSettingsPayload } from "./tool";
|
||||||
export {
|
export {
|
||||||
viewportCommands,
|
viewportCommands,
|
||||||
viewportPanCommand,
|
viewportPanCommand,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import type {
|
|||||||
DocumentUngroupLayerPayload,
|
DocumentUngroupLayerPayload,
|
||||||
} from "./document";
|
} from "./document";
|
||||||
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
|
||||||
import type { ToolSetActivePayload } from "./tool";
|
import type { ToolSetActivePayload, ToolSetBrushSettingsPayload } from "./tool";
|
||||||
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform";
|
||||||
import type {
|
import type {
|
||||||
ViewportFitArtboardPayload,
|
ViewportFitArtboardPayload,
|
||||||
@@ -55,6 +55,7 @@ export type CommandPayloads = {
|
|||||||
[commandIds.selectionClear]: void;
|
[commandIds.selectionClear]: void;
|
||||||
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
|
[commandIds.selectionAddLayer]: SelectionAddLayerPayload;
|
||||||
[commandIds.toolSetActive]: ToolSetActivePayload;
|
[commandIds.toolSetActive]: ToolSetActivePayload;
|
||||||
|
[commandIds.toolSetBrushSettings]: ToolSetBrushSettingsPayload;
|
||||||
[commandIds.toolEnterTemporaryPan]: void;
|
[commandIds.toolEnterTemporaryPan]: void;
|
||||||
[commandIds.toolExitTemporaryPan]: void;
|
[commandIds.toolExitTemporaryPan]: void;
|
||||||
[commandIds.transformBegin]: TransformBeginPayload;
|
[commandIds.transformBegin]: TransformBeginPayload;
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
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 { toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand } from "./tool";
|
import { toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushSettingsCommand } from "./tool";
|
||||||
|
|
||||||
describe("tool commands", () => {
|
describe("tool commands", () => {
|
||||||
test("sets active tool", () => {
|
test("sets active tool", () => {
|
||||||
const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "crop" });
|
const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "crop" });
|
||||||
expect(next.editor.tools).toEqual({ activeTool: "crop", interactionMode: { type: "tool", tool: "crop" } });
|
expect(next.editor.tools).toEqual({ activeTool: "crop", interactionMode: { type: "tool", tool: "crop" }, brush: { color: "#111827", size: 8, hardness: 100 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets brush settings", () => {
|
||||||
|
const next = toolSetBrushSettingsCommand.execute({ state: createInitialAppState("Test") }, { color: "#ff0000", size: 24, hardness: 50 });
|
||||||
|
|
||||||
|
expect(next.editor.tools.brush).toEqual({ color: "#ff0000", size: 24, hardness: 50 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("enters and exits temporary pan", () => {
|
test("enters and exits temporary pan", () => {
|
||||||
@@ -13,7 +19,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" } });
|
expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" }, brush: { color: "#111827", size: 8, hardness: 100 } });
|
||||||
expect(restored.editor.tools).toEqual(initial.editor.tools);
|
expect(restored.editor.tools).toEqual(initial.editor.tools);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ToolId } from "@editor/tools";
|
import type { BrushSettings, ToolId } from "@editor/tools";
|
||||||
import type { Command } from "./command";
|
import type { Command } from "./command";
|
||||||
import { commandIds } from "./ids";
|
import { commandIds } from "./ids";
|
||||||
|
|
||||||
@@ -6,6 +6,8 @@ export type ToolSetActivePayload = {
|
|||||||
tool: ToolId;
|
tool: ToolId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ToolSetBrushSettingsPayload = Partial<BrushSettings>;
|
||||||
|
|
||||||
export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
|
export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
|
||||||
id: commandIds.toolSetActive,
|
id: commandIds.toolSetActive,
|
||||||
name: "Set active tool",
|
name: "Set active tool",
|
||||||
@@ -15,6 +17,7 @@ export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
|
|||||||
editor: {
|
editor: {
|
||||||
...state.editor,
|
...state.editor,
|
||||||
tools: {
|
tools: {
|
||||||
|
...state.editor.tools,
|
||||||
activeTool: payload.tool,
|
activeTool: payload.tool,
|
||||||
interactionMode: { type: "tool", tool: payload.tool },
|
interactionMode: { type: "tool", tool: payload.tool },
|
||||||
},
|
},
|
||||||
@@ -23,6 +26,27 @@ export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const toolSetBrushSettingsCommand: Command<ToolSetBrushSettingsPayload> = {
|
||||||
|
id: commandIds.toolSetBrushSettings,
|
||||||
|
name: "Set brush settings",
|
||||||
|
execute({ state }, payload) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
editor: {
|
||||||
|
...state.editor,
|
||||||
|
tools: {
|
||||||
|
...state.editor.tools,
|
||||||
|
brush: {
|
||||||
|
color: payload.color ?? state.editor.tools.brush.color,
|
||||||
|
size: clampNumber(payload.size ?? state.editor.tools.brush.size, 1, 200),
|
||||||
|
hardness: clampNumber(payload.hardness ?? state.editor.tools.brush.hardness, 0, 100),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const toolEnterTemporaryPanCommand: Command = {
|
export const toolEnterTemporaryPanCommand: Command = {
|
||||||
id: commandIds.toolEnterTemporaryPan,
|
id: commandIds.toolEnterTemporaryPan,
|
||||||
name: "Enter temporary pan",
|
name: "Enter temporary pan",
|
||||||
@@ -54,6 +78,7 @@ export const toolExitTemporaryPanCommand: Command = {
|
|||||||
editor: {
|
editor: {
|
||||||
...state.editor,
|
...state.editor,
|
||||||
tools: {
|
tools: {
|
||||||
|
...state.editor.tools,
|
||||||
activeTool: mode.previousTool,
|
activeTool: mode.previousTool,
|
||||||
interactionMode: { type: "tool", tool: mode.previousTool },
|
interactionMode: { type: "tool", tool: mode.previousTool },
|
||||||
},
|
},
|
||||||
@@ -62,4 +87,9 @@ export const toolExitTemporaryPanCommand: Command = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toolCommands = [toolSetActiveCommand, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand] satisfies Command<unknown>[];
|
export const toolCommands = [toolSetActiveCommand, toolSetBrushSettingsCommand, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand] satisfies Command<unknown>[];
|
||||||
|
|
||||||
|
function clampNumber(value: number, min: number, max: number) {
|
||||||
|
if (!Number.isFinite(value)) return min;
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,14 +6,22 @@ export type InteractionMode =
|
|||||||
| { type: "tool"; tool: ToolId }
|
| { type: "tool"; tool: ToolId }
|
||||||
| { type: "temporary-pan"; previousTool: ToolId };
|
| { type: "temporary-pan"; previousTool: ToolId };
|
||||||
|
|
||||||
|
export type BrushSettings = {
|
||||||
|
color: string;
|
||||||
|
size: number;
|
||||||
|
hardness: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type ToolState = {
|
export type ToolState = {
|
||||||
activeTool: ToolId;
|
activeTool: ToolId;
|
||||||
interactionMode: InteractionMode;
|
interactionMode: InteractionMode;
|
||||||
|
brush: BrushSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initialToolState: ToolState = {
|
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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isPanInteractionMode(interactionMode: InteractionMode): boolean {
|
export function isPanInteractionMode(interactionMode: InteractionMode): boolean {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ describe("transform controls input", () => {
|
|||||||
...createInitialAppState("Test").editor,
|
...createInitialAppState("Test").editor,
|
||||||
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
|
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
|
||||||
selection: { artboardId: "a1", layerIds: [] },
|
selection: { artboardId: "a1", layerIds: [] },
|
||||||
tools: { activeTool: "select" as const, interactionMode: { type: "temporary-pan" as const, previousTool: "select" as const } },
|
tools: { activeTool: "select" as const, interactionMode: { type: "temporary-pan" as const, previousTool: "select" as const }, brush: { color: "#111827", size: 8, hardness: 100 } },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const dispatched: unknown[] = [];
|
const dispatched: unknown[] = [];
|
||||||
@@ -55,7 +55,7 @@ describe("transform controls input", () => {
|
|||||||
...createInitialAppState("Test").editor,
|
...createInitialAppState("Test").editor,
|
||||||
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
|
viewport: { center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 200, h: 200 } },
|
||||||
selection: { artboardId: "a1", layerIds: [] },
|
selection: { artboardId: "a1", layerIds: [] },
|
||||||
tools: { activeTool: "crop" as const, interactionMode: { type: "tool" as const, tool: "crop" as const } },
|
tools: { activeTool: "crop" as const, interactionMode: { type: "tool" as const, tool: "crop" as const }, brush: { color: "#111827", size: 8, hardness: 100 } },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const dispatched: unknown[] = [];
|
const dispatched: unknown[] = [];
|
||||||
|
|||||||
@@ -81,8 +81,10 @@ export function App({ app }: AppProps) {
|
|||||||
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
|
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
|
||||||
<BottomControlsIsland
|
<BottomControlsIsland
|
||||||
viewport={state.editor.viewport}
|
viewport={state.editor.viewport}
|
||||||
visible={Boolean(transformBounds) || viewportActivityIsland.visible}
|
visible={state.editor.tools.activeTool === "brush" || state.editor.tools.activeTool === "eraser" || Boolean(transformBounds) || viewportActivityIsland.visible}
|
||||||
action={viewportActivityIsland.action}
|
action={viewportActivityIsland.action}
|
||||||
|
activeTool={state.editor.tools.activeTool}
|
||||||
|
brushSettings={state.editor.tools.brush}
|
||||||
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
|
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
|
||||||
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
|
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
|
||||||
dispatch={app.store.dispatch}
|
dispatch={app.store.dispatch}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { AppStore } from "@editor/store";
|
import type { AppStore } from "@editor/store";
|
||||||
import type { ViewportState } from "@editor/state";
|
import type { ViewportState } from "@editor/state";
|
||||||
|
import type { BrushSettings, ToolId } from "@editor/tools";
|
||||||
|
import { BrushControls } from "./bottom-controls/BrushControls";
|
||||||
import { PanControls } from "./bottom-controls/PanControls";
|
import { PanControls } from "./bottom-controls/PanControls";
|
||||||
import { TransformControls } from "./bottom-controls/TransformControls";
|
import { TransformControls } from "./bottom-controls/TransformControls";
|
||||||
import { ZoomControls } from "./bottom-controls/ZoomControls";
|
import { ZoomControls } from "./bottom-controls/ZoomControls";
|
||||||
@@ -11,12 +13,14 @@ export type BottomControlsIslandProps = {
|
|||||||
viewport: ViewportState;
|
viewport: ViewportState;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
action: BottomControlsAction;
|
action: BottomControlsAction;
|
||||||
|
activeTool: ToolId;
|
||||||
|
brushSettings: BrushSettings;
|
||||||
transformBounds?: Rect;
|
transformBounds?: Rect;
|
||||||
transformTarget?: TransformTarget;
|
transformTarget?: TransformTarget;
|
||||||
dispatch: AppStore["dispatch"];
|
dispatch: AppStore["dispatch"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BottomControlsIsland({ viewport, visible, action, transformBounds, transformTarget, dispatch }: BottomControlsIslandProps) {
|
export function BottomControlsIsland({ viewport, visible, action, activeTool, brushSettings, transformBounds, transformTarget, dispatch }: BottomControlsIslandProps) {
|
||||||
const zoomPercent = Math.round(viewport.zoom * 100);
|
const zoomPercent = Math.round(viewport.zoom * 100);
|
||||||
const x = Math.round(viewport.center.x);
|
const x = Math.round(viewport.center.x);
|
||||||
const y = Math.round(viewport.center.y);
|
const y = Math.round(viewport.center.y);
|
||||||
@@ -28,7 +32,9 @@ export function BottomControlsIsland({ viewport, visible, action, transformBound
|
|||||||
visible ? "pointer-events-auto translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"
|
visible ? "pointer-events-auto translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{transformBounds && transformTarget ? (
|
{activeTool === "brush" || activeTool === "eraser" ? (
|
||||||
|
<BrushControls tool={activeTool} settings={brushSettings} dispatch={dispatch} />
|
||||||
|
) : transformBounds && transformTarget ? (
|
||||||
<TransformControls bounds={transformBounds} target={transformTarget} dispatch={dispatch} />
|
<TransformControls bounds={transformBounds} target={transformTarget} dispatch={dispatch} />
|
||||||
) : action === "pan" ? (
|
) : action === "pan" ? (
|
||||||
<PanControls x={x} y={y} />
|
<PanControls x={x} y={y} />
|
||||||
|
|||||||
59
view/bottom-controls/BrushControls.tsx
Normal file
59
view/bottom-controls/BrushControls.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { commandIds } from "@commands/ids";
|
||||||
|
import type { AppStore } from "@editor/store";
|
||||||
|
import type { BrushSettings, ToolId } from "@editor/tools";
|
||||||
|
import { BottomControlDivider } from "./Divider";
|
||||||
|
import { bottomControlLabelClass } from "./styles";
|
||||||
|
|
||||||
|
export type BrushControlsProps = {
|
||||||
|
tool: Extract<ToolId, "brush" | "eraser">;
|
||||||
|
settings: BrushSettings;
|
||||||
|
dispatch: AppStore["dispatch"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BrushControls({ tool, settings, dispatch }: BrushControlsProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center justify-center gap-2 tabular-nums">
|
||||||
|
<span className="px-2 font-medium text-white/85">{tool === "eraser" ? "Eraser" : "Brush"}</span>
|
||||||
|
<BottomControlDivider />
|
||||||
|
{tool === "brush" ? (
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<span className={bottomControlLabelClass()}>Color</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="h-7 w-8 cursor-pointer rounded border-0 bg-transparent p-0"
|
||||||
|
value={settings.color}
|
||||||
|
aria-label="Brush color"
|
||||||
|
onChange={(event) => dispatch(commandIds.toolSetBrushSettings, { color: event.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<span className={bottomControlLabelClass()}>Size</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={200}
|
||||||
|
value={settings.size}
|
||||||
|
className="w-24 accent-white"
|
||||||
|
aria-label={`${tool} size`}
|
||||||
|
onChange={(event) => dispatch(commandIds.toolSetBrushSettings, { size: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
<span className="w-8 text-right text-white">{Math.round(settings.size)}</span>
|
||||||
|
</label>
|
||||||
|
<BottomControlDivider />
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<span className={bottomControlLabelClass()}>Hard</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={settings.hardness}
|
||||||
|
className="w-24 accent-white"
|
||||||
|
aria-label={`${tool} hardness`}
|
||||||
|
onChange={(event) => dispatch(commandIds.toolSetBrushSettings, { hardness: Number(event.target.value) })}
|
||||||
|
/>
|
||||||
|
<span className="w-8 text-right text-white">{Math.round(settings.hardness)}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,8 +25,9 @@ export async function updateBrushSession(options: {
|
|||||||
store: AppStore;
|
store: AppStore;
|
||||||
session: BrushSession;
|
session: BrushSession;
|
||||||
point: Vec2D;
|
point: Vec2D;
|
||||||
color?: string;
|
color: string;
|
||||||
size?: number;
|
size: number;
|
||||||
|
hardness: number;
|
||||||
}): Promise<BrushSession> {
|
}): Promise<BrushSession> {
|
||||||
const state = options.store.getState();
|
const state = options.store.getState();
|
||||||
const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId);
|
const layer = findRasterLayer(state.document.artboards.flatMap((artboard) => artboard.layers), options.session.layerId);
|
||||||
@@ -41,8 +42,9 @@ export async function updateBrushSession(options: {
|
|||||||
height: asset.intrinsicSize.h,
|
height: asset.intrinsicSize.h,
|
||||||
from: documentPointToAssetPoint(options.session.previousPoint, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
|
from: documentPointToAssetPoint(options.session.previousPoint, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
|
||||||
to: documentPointToAssetPoint(options.point, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
|
to: documentPointToAssetPoint(options.point, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
|
||||||
color: options.color ?? "#111827",
|
color: options.color,
|
||||||
size: options.size ?? 8,
|
size: options.size,
|
||||||
|
hardness: options.hardness,
|
||||||
mode: options.session.mode,
|
mode: options.session.mode,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,6 +67,7 @@ async function drawStroke(options: {
|
|||||||
to: Vec2D;
|
to: Vec2D;
|
||||||
color: string;
|
color: string;
|
||||||
size: number;
|
size: number;
|
||||||
|
hardness: number;
|
||||||
mode: "brush" | "eraser";
|
mode: "brush" | "eraser";
|
||||||
}) {
|
}) {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
@@ -75,8 +78,11 @@ async function drawStroke(options: {
|
|||||||
|
|
||||||
const image = await loadImage(options.source);
|
const image = await loadImage(options.source);
|
||||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||||
|
const hardness = Math.max(0, Math.min(100, options.hardness)) / 100;
|
||||||
context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over";
|
context.globalCompositeOperation = options.mode === "eraser" ? "destination-out" : "source-over";
|
||||||
context.strokeStyle = options.color;
|
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.lineWidth = options.size;
|
||||||
context.lineCap = "round";
|
context.lineCap = "round";
|
||||||
context.lineJoin = "round";
|
context.lineJoin = "round";
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ export function useCanvasInput(
|
|||||||
if (brushSession.current) {
|
if (brushSession.current) {
|
||||||
const activeSessionId = brushSessionId.current;
|
const activeSessionId = brushSessionId.current;
|
||||||
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
|
const point = viewportPointToDocumentPoint(inputEvent.position, store.getState().editor.viewport);
|
||||||
void updateBrushSession({ store, session: brushSession.current, point }).then((nextSession) => {
|
const settings = store.getState().editor.tools.brush;
|
||||||
|
void updateBrushSession({ store, session: brushSession.current, point, color: settings.color, size: settings.size, hardness: settings.hardness }).then((nextSession) => {
|
||||||
if (brushSessionId.current === activeSessionId) brushSession.current = nextSession;
|
if (brushSessionId.current === activeSessionId) brushSession.current = nextSession;
|
||||||
});
|
});
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
Reference in New Issue
Block a user