feat(tools): add brush controls
This commit is contained in:
@@ -81,8 +81,10 @@ export function App({ app }: AppProps) {
|
||||
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
|
||||
<BottomControlsIsland
|
||||
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}
|
||||
activeTool={state.editor.tools.activeTool}
|
||||
brushSettings={state.editor.tools.brush}
|
||||
transformBounds={viewportActivityIsland.visible ? undefined : transformBounds}
|
||||
transformTarget={viewportActivityIsland.visible ? undefined : transformTarget}
|
||||
dispatch={app.store.dispatch}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { AppStore } from "@editor/store";
|
||||
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 { TransformControls } from "./bottom-controls/TransformControls";
|
||||
import { ZoomControls } from "./bottom-controls/ZoomControls";
|
||||
@@ -11,12 +13,14 @@ export type BottomControlsIslandProps = {
|
||||
viewport: ViewportState;
|
||||
visible: boolean;
|
||||
action: BottomControlsAction;
|
||||
activeTool: ToolId;
|
||||
brushSettings: BrushSettings;
|
||||
transformBounds?: Rect;
|
||||
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 x = Math.round(viewport.center.x);
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{transformBounds && transformTarget ? (
|
||||
{activeTool === "brush" || activeTool === "eraser" ? (
|
||||
<BrushControls tool={activeTool} settings={brushSettings} dispatch={dispatch} />
|
||||
) : transformBounds && transformTarget ? (
|
||||
<TransformControls bounds={transformBounds} target={transformTarget} dispatch={dispatch} />
|
||||
) : action === "pan" ? (
|
||||
<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;
|
||||
session: BrushSession;
|
||||
point: Vec2D;
|
||||
color?: string;
|
||||
size?: number;
|
||||
color: string;
|
||||
size: number;
|
||||
hardness: number;
|
||||
}): Promise<BrushSession> {
|
||||
const state = options.store.getState();
|
||||
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,
|
||||
from: documentPointToAssetPoint(options.session.previousPoint, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
|
||||
to: documentPointToAssetPoint(options.point, layer, asset.intrinsicSize.w, asset.intrinsicSize.h),
|
||||
color: options.color ?? "#111827",
|
||||
size: options.size ?? 8,
|
||||
color: options.color,
|
||||
size: options.size,
|
||||
hardness: options.hardness,
|
||||
mode: options.session.mode,
|
||||
});
|
||||
|
||||
@@ -65,6 +67,7 @@ async function drawStroke(options: {
|
||||
to: Vec2D;
|
||||
color: string;
|
||||
size: number;
|
||||
hardness: number;
|
||||
mode: "brush" | "eraser";
|
||||
}) {
|
||||
const canvas = document.createElement("canvas");
|
||||
@@ -75,8 +78,11 @@ async function drawStroke(options: {
|
||||
|
||||
const image = await loadImage(options.source);
|
||||
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.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";
|
||||
|
||||
@@ -103,7 +103,8 @@ export function useCanvasInput(
|
||||
if (brushSession.current) {
|
||||
const activeSessionId = brushSessionId.current;
|
||||
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;
|
||||
});
|
||||
event.preventDefault();
|
||||
|
||||
Reference in New Issue
Block a user