2026-05-21 00:02:12 +07:00
|
|
|
export type BrushOptions = {
|
|
|
|
|
color: string;
|
|
|
|
|
size: number;
|
|
|
|
|
opacity: number;
|
2026-05-31 03:03:49 +07:00
|
|
|
hardness: number; // 0 to 1: 0 is fully soft, 1 is a hard edge.
|
2026-05-21 00:02:12 +07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type BrushStroke = {
|
|
|
|
|
canvas: HTMLCanvasElement;
|
|
|
|
|
ctx: CanvasRenderingContext2D;
|
|
|
|
|
width: number;
|
|
|
|
|
height: number;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-24 21:05:21 +07:00
|
|
|
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 };
|
2026-05-21 00:02:12 +07:00
|
|
|
return {
|
|
|
|
|
r: parseInt(clean.slice(0, 2), 16),
|
|
|
|
|
g: parseInt(clean.slice(2, 4), 16),
|
|
|
|
|
b: parseInt(clean.slice(4, 6), 16),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 21:05:21 +07:00
|
|
|
export function clampBrushOptions(options: BrushOptions): BrushOptions {
|
|
|
|
|
return {
|
|
|
|
|
color: options.color,
|
|
|
|
|
size: Math.max(1, Number.isFinite(options.size) ? options.size : 1),
|
2026-05-31 03:03:49 +07:00
|
|
|
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),
|
|
|
|
|
),
|
2026-05-24 21:05:21 +07:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
export function buildBrushDabs(
|
|
|
|
|
x0: number,
|
|
|
|
|
y0: number,
|
|
|
|
|
x1: number,
|
|
|
|
|
y1: number,
|
|
|
|
|
size: number,
|
|
|
|
|
): BrushDab[] {
|
2026-05-24 21:05:21 +07:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 00:02:12 +07:00
|
|
|
function drawDab(
|
|
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
|
x: number,
|
|
|
|
|
y: number,
|
|
|
|
|
options: BrushOptions,
|
|
|
|
|
) {
|
2026-05-24 21:05:21 +07:00
|
|
|
const normalized = clampBrushOptions(options);
|
|
|
|
|
const r = normalized.size / 2;
|
|
|
|
|
const { r: cr, g: cg, b: cb } = hexToRgb(normalized.color);
|
2026-05-21 00:02:12 +07:00
|
|
|
|
|
|
|
|
const gradient = ctx.createRadialGradient(x, y, 0, x, y, r);
|
|
|
|
|
|
2026-05-24 21:05:21 +07:00
|
|
|
gradient.addColorStop(0, `rgba(${cr},${cg},${cb},${normalized.opacity})`);
|
2026-05-31 03:03:49 +07:00
|
|
|
gradient.addColorStop(
|
|
|
|
|
normalized.hardness,
|
|
|
|
|
`rgba(${cr},${cg},${cb},${normalized.opacity})`,
|
|
|
|
|
);
|
2026-05-21 00:02:12 +07:00
|
|
|
gradient.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
|
|
|
|
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.arc(x, y, r, 0, Math.PI * 2);
|
|
|
|
|
ctx.fillStyle = gradient;
|
|
|
|
|
ctx.fill();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createStroke(width: number, height: number): BrushStroke {
|
|
|
|
|
const canvas = document.createElement("canvas");
|
|
|
|
|
canvas.width = Math.max(1, Math.round(width));
|
|
|
|
|
canvas.height = Math.max(1, Math.round(height));
|
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
|
if (!ctx) throw new Error("Cannot create brush canvas context");
|
|
|
|
|
return { canvas, ctx, width: canvas.width, height: canvas.height };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function paintSegment(
|
|
|
|
|
stroke: BrushStroke,
|
|
|
|
|
x0: number,
|
|
|
|
|
y0: number,
|
|
|
|
|
x1: number,
|
|
|
|
|
y1: number,
|
|
|
|
|
options: BrushOptions,
|
|
|
|
|
) {
|
2026-05-24 21:05:21 +07:00
|
|
|
const normalized = clampBrushOptions(options);
|
|
|
|
|
for (const dab of buildBrushDabs(x0, y0, x1, y1, normalized.size)) {
|
|
|
|
|
drawDab(stroke.ctx, dab.x, dab.y, normalized);
|
2026-05-21 00:02:12 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
export function commitStroke(
|
|
|
|
|
sourceUri: string,
|
|
|
|
|
stroke: BrushStroke,
|
|
|
|
|
): Promise<string> {
|
2026-05-21 00:02:12 +07:00
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const image = new Image();
|
|
|
|
|
image.crossOrigin = "anonymous";
|
|
|
|
|
image.onload = () => {
|
|
|
|
|
const canvas = document.createElement("canvas");
|
|
|
|
|
canvas.width = image.naturalWidth;
|
|
|
|
|
canvas.height = image.naturalHeight;
|
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
|
if (!ctx) {
|
|
|
|
|
reject(new Error("Cannot create merge canvas context"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
ctx.drawImage(image, 0, 0);
|
|
|
|
|
ctx.drawImage(stroke.canvas, 0, 0, canvas.width, canvas.height);
|
|
|
|
|
resolve(canvas.toDataURL("image/png"));
|
|
|
|
|
};
|
|
|
|
|
image.onerror = reject;
|
|
|
|
|
image.src = sourceUri;
|
|
|
|
|
});
|
|
|
|
|
}
|