Files
image-studio/view/canvas/brush.ts
2026-07-03 17:56:41 +02:00

118 lines
4.5 KiB
TypeScript

import { commandIds } from "@commands/ids";
import type { ImageDocument } from "@core/document";
import type { Vec2D } from "@core/geometry";
import type { Layer } from "@core/layer";
import type { RasterLayer } from "@core/raster-layer";
import type { EditorState } from "@editor/state";
import { isPanInteractionMode } from "@editor/tools";
import type { AppStore } from "@editor/store";
export type BrushSession = {
layerId: string;
previousPoint: Vec2D;
mode: "brush" | "eraser";
};
export function beginBrushSession(document: ImageDocument, editor: EditorState, point: Vec2D): BrushSession | undefined {
if (isPanInteractionMode(editor.tools.interactionMode) || (editor.tools.activeTool !== "brush" && editor.tools.activeTool !== "eraser")) return undefined;
const layerId = editor.selection.layerIds[0];
if (!layerId) return undefined;
const layer = findRasterLayer(document.artboards.flatMap((artboard) => artboard.layers), layerId);
if (!layer || layer.locked || !layer.visible) return undefined;
return { layerId, previousPoint: point, mode: editor.tools.activeTool };
}
export async function updateBrushSession(options: {
store: AppStore;
session: BrushSession;
point: Vec2D;
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);
if (!layer) return { ...options.session, previousPoint: options.point };
const asset = state.document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return { ...options.session, previousPoint: options.point };
const source = await drawStroke({
source: asset.source,
width: asset.intrinsicSize.w,
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,
size: options.size,
hardness: options.hardness,
mode: options.session.mode,
});
options.store.dispatch(commandIds.documentUpdateAssetSource, { assetId: asset.id, source });
return { ...options.session, previousPoint: options.point };
}
function documentPointToAssetPoint(point: Vec2D, layer: RasterLayer, width: number, height: number): Vec2D {
return {
x: ((point.x - layer.transform.position.x) / Math.max(0.0001, layer.transform.scale.x) / width) * width,
y: ((point.y - layer.transform.position.y) / Math.max(0.0001, layer.transform.scale.y) / height) * height,
};
}
async function drawStroke(options: {
source: string;
width: number;
height: number;
from: Vec2D;
to: Vec2D;
color: string;
size: number;
hardness: number;
mode: "brush" | "eraser";
}) {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(options.width));
canvas.height = Math.max(1, Math.round(options.height));
const context = canvas.getContext("2d");
if (!context) return options.source;
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";
context.beginPath();
context.moveTo(options.from.x, options.from.y);
context.lineTo(options.to.x, options.to.y);
context.stroke();
context.globalCompositeOperation = "source-over";
return canvas.toDataURL("image/png");
}
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 findRasterLayer(layers: Layer[], layerId: string): RasterLayer | undefined {
for (const layer of layers) {
if (layer.id === layerId && layer.type === "raster") return layer;
if (layer.type === "group") {
const child = findRasterLayer(layer.children, layerId);
if (child) return child;
}
}
return undefined;
}