feat: more tests, refactored

This commit is contained in:
2026-05-24 21:05:21 +07:00
parent e7dd9420e7
commit c1f12c7201
33 changed files with 1403 additions and 334 deletions
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { buildBrushDabs, clampBrushOptions, hexToRgb } from "./brush-painter";
describe("brush painter pure helpers", () => {
it("parses valid hex colors and falls back to black", () => {
expect(hexToRgb("#ff8040")).toEqual({ r: 255, g: 128, b: 64 });
expect(hexToRgb("00aaee")).toEqual({ r: 0, g: 170, b: 238 });
expect(hexToRgb("nope")).toEqual({ r: 0, g: 0, b: 0 });
});
it("clamps invalid brush options", () => {
expect(clampBrushOptions({ color: "#fff", size: 0, opacity: 2, hardness: -1 })).toEqual({
color: "#fff",
size: 1,
opacity: 1,
hardness: 0,
});
expect(clampBrushOptions({ color: "#000", size: Number.NaN, opacity: Number.NaN, hardness: Number.NaN })).toEqual({
color: "#000",
size: 1,
opacity: 1,
hardness: 1,
});
});
it("interpolates brush dabs including endpoints", () => {
expect(buildBrushDabs(0, 0, 4, 0, 4)).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 2, y: 0 },
{ x: 3, y: 0 },
{ x: 4, y: 0 },
]);
expect(buildBrushDabs(2, 3, 2, 3, 10)).toEqual([{ x: 2, y: 3 }, { x: 2, y: 3 }]);
});
});
+41 -16
View File
@@ -12,8 +12,14 @@ export type BrushStroke = {
height: number;
};
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const clean = hex.replace("#", "");
export type BrushDab = {
x: number;
y: number;
};
export function hexToRgb(hex: string): { r: number; g: number; b: number } {
const clean = hex.replace("#", "").trim();
if (!/^[0-9a-fA-F]{6}$/.test(clean)) return { r: 0, g: 0, b: 0 };
return {
r: parseInt(clean.slice(0, 2), 16),
g: parseInt(clean.slice(2, 4), 16),
@@ -21,20 +27,45 @@ function hexToRgb(hex: string): { r: number; g: number; b: number } {
};
}
export function clampBrushOptions(options: BrushOptions): BrushOptions {
return {
color: options.color,
size: Math.max(1, Number.isFinite(options.size) ? options.size : 1),
opacity: Math.max(0, Math.min(1, Number.isFinite(options.opacity) ? options.opacity : 1)),
hardness: Math.max(0, Math.min(1, Number.isFinite(options.hardness) ? options.hardness : 1)),
};
}
export function buildBrushDabs(x0: number, y0: number, x1: number, y1: number, size: number): BrushDab[] {
const dx = x1 - x0;
const dy = y1 - y0;
const dist = Math.sqrt(dx * dx + dy * dy);
const step = Math.max(1, size * 0.25);
const steps = Math.max(1, Math.ceil(dist / step));
const dabs: BrushDab[] = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
dabs.push({ x: x0 + dx * t, y: y0 + dy * t });
}
return dabs;
}
function drawDab(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
options: BrushOptions,
) {
const r = options.size / 2;
const { r: cr, g: cg, b: cb } = hexToRgb(options.color);
const normalized = clampBrushOptions(options);
const r = normalized.size / 2;
const { r: cr, g: cg, b: cb } = hexToRgb(normalized.color);
const gradient = ctx.createRadialGradient(x, y, 0, x, y, r);
const innerStop = Math.max(0, Math.min(1, options.hardness));
gradient.addColorStop(0, `rgba(${cr},${cg},${cb},${options.opacity})`);
gradient.addColorStop(innerStop, `rgba(${cr},${cg},${cb},${options.opacity})`);
gradient.addColorStop(0, `rgba(${cr},${cg},${cb},${normalized.opacity})`);
gradient.addColorStop(normalized.hardness, `rgba(${cr},${cg},${cb},${normalized.opacity})`);
gradient.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
ctx.beginPath();
@@ -62,15 +93,9 @@ export function paintSegment(
y1: number,
options: BrushOptions,
) {
const dx = x1 - x0;
const dy = y1 - y0;
const dist = Math.sqrt(dx * dx + dy * dy);
const step = Math.max(1, options.size * 0.25);
const steps = Math.max(1, Math.ceil(dist / step));
for (let i = 0; i <= steps; i++) {
const t = steps === 0 ? 0 : i / steps;
drawDab(stroke.ctx, x0 + dx * t, y0 + dy * t, options);
const normalized = clampBrushOptions(options);
for (const dab of buildBrushDabs(x0, y0, x1, y1, normalized.size)) {
drawDab(stroke.ctx, dab.x, dab.y, normalized);
}
}
-26
View File
@@ -14,29 +14,3 @@ export type ToolActionController = {
};
export type EditorToolController = ToolModeController | ToolActionController;
type CreateEditorToolControllersOptions = {
onAddTextLayer: () => void;
onImportImage: () => void;
labels: {
pointer: string;
pan: string;
face: string;
fill: string;
brush: string;
text: string;
image: string;
};
};
export function createEditorToolControllers(options: CreateEditorToolControllersOptions): EditorToolController[] {
return [
{ kind: "mode", id: "pointer", label: options.labels.pointer },
{ kind: "mode", id: "hand", label: options.labels.pan },
{ kind: "mode", id: "face", label: options.labels.face },
{ kind: "mode", id: "fill", label: options.labels.fill },
{ kind: "mode", id: "brush", label: options.labels.brush },
{ kind: "action", id: "add-text", label: options.labels.text, run: options.onAddTextLayer },
{ kind: "action", id: "import-image", label: options.labels.image, run: options.onImportImage },
];
}
+1 -1
View File
@@ -136,7 +136,7 @@ function renderLayer(context: EffectRenderContext, effect: FaceBlurEffect) {
ctx.drawImage(sourceCanvas, 0, 0, sourceCanvas.width, sourceCanvas.height, 0, 0, targetWidth, targetHeight);
}
export const faceBlurRenderer: EffectRenderer<FaceBlurEffect> = {
export const faceBlurRenderer: EffectRenderer = {
kind: "face-blur",
render: ({ ctx, image, targetWidth, targetHeight }, effect) => {
renderRegions(ctx, image, effect, targetWidth, targetHeight);
+2 -2
View File
@@ -35,12 +35,12 @@ export function renderLayerWithEffects(
// The first effect owns the full layer render (draws base image + applies itself)
const first = activeEffects[0];
const firstRenderer = effectRendererRegistry.get(first.kind);
firstRenderer?.renderLayer(context, first as never);
firstRenderer?.renderLayer(context, first);
// Subsequent effects render on top (overlay only, no re-draw of base)
for (let i = 1; i < activeEffects.length; i++) {
const effect = activeEffects[i];
const renderer = effectRendererRegistry.get(effect.kind);
renderer?.render(context, effect as never);
renderer?.render(context, effect);
}
}
+4 -4
View File
@@ -7,10 +7,10 @@ export type EffectRenderContext = {
targetHeight: number;
};
export type EffectRenderer<T extends LayerEffect = LayerEffect> = {
kind: T["kind"];
export type EffectRenderer = {
kind: LayerEffect["kind"];
/** Renders the effect onto the canvas. Called after the base image is drawn. */
render: (context: EffectRenderContext, effect: T) => void;
render: (context: EffectRenderContext, effect: LayerEffect) => void;
/** Renders the full layer (image + effect). Called instead of a plain drawImage. */
renderLayer: (context: EffectRenderContext, effect: T) => void;
renderLayer: (context: EffectRenderContext, effect: LayerEffect) => void;
};
+7
View File
@@ -47,6 +47,13 @@ describe("hasProjectChanged", () => {
expect(hasProjectChanged(a, b)).toBe(true);
});
it("detects visibility changes", () => {
const a = makeProject();
const b = makeProject();
b.layers[0].visible = false;
expect(hasProjectChanged(a, b)).toBe(true);
});
it("treats missing optional fields and undefined as equal", () => {
const a = makeProject();
const b = makeProject();
+2 -1
View File
@@ -27,7 +27,8 @@ export function hasProjectChanged(left: Project, right: Project): boolean {
a.height !== b.height ||
a.scale !== b.scale ||
a.rotation !== b.rotation ||
a.opacity !== b.opacity
a.opacity !== b.opacity ||
a.visible !== b.visible
) {
return true;
}