227 lines
6.2 KiB
TypeScript
227 lines
6.2 KiB
TypeScript
export type DrawTool = 'pen' | 'arrow' | 'brush' | 'neon' | 'eraser';
|
|
|
|
export const ARROW_ANIMATION_DURATION = 200;
|
|
const offscreen = document.createElement('canvas');
|
|
|
|
export function getEffectiveDimensions(imgWidth: number, imgHeight: number, quarterTurns: number) {
|
|
const isSideways = quarterTurns % 2 === 1;
|
|
return {
|
|
width: isSideways ? imgHeight : imgWidth,
|
|
height: isSideways ? imgWidth : imgHeight,
|
|
};
|
|
}
|
|
|
|
export function computeRotationZoom(effectiveW: number, effectiveH: number, fineRotation: number) {
|
|
if (fineRotation === 0 || effectiveW <= 0 || effectiveH <= 0) return 1;
|
|
const rad = Math.abs(fineRotation * Math.PI / 180);
|
|
const cos = Math.cos(rad);
|
|
const sin = Math.sin(rad);
|
|
return Math.max(
|
|
cos + (effectiveH / effectiveW) * sin,
|
|
(effectiveW / effectiveH) * sin + cos,
|
|
);
|
|
}
|
|
|
|
export interface DrawAction {
|
|
type: 'draw';
|
|
tool: DrawTool;
|
|
points: Array<{ x: number; y: number }>;
|
|
color: string;
|
|
brushSize: number;
|
|
completedAt?: number;
|
|
isShiftPressed?: boolean;
|
|
}
|
|
|
|
export function renderDrawAction(
|
|
ctx: CanvasRenderingContext2D,
|
|
action: DrawAction,
|
|
offsetX = 0,
|
|
offsetY = 0,
|
|
isComplete = true,
|
|
) {
|
|
if (action.points.length < 2) return;
|
|
|
|
ctx.save();
|
|
|
|
if (action.tool === 'eraser') {
|
|
ctx.globalCompositeOperation = 'destination-out';
|
|
ctx.strokeStyle = 'rgba(0,0,0,1)';
|
|
ctx.lineWidth = action.brushSize * 2;
|
|
} else if (action.tool === 'neon') {
|
|
ctx.shadowColor = action.color;
|
|
ctx.shadowBlur = action.brushSize * 2;
|
|
ctx.strokeStyle = action.color;
|
|
ctx.lineWidth = action.brushSize * 0.5;
|
|
} else if (action.tool === 'brush') {
|
|
ctx.globalAlpha = 0.4;
|
|
ctx.strokeStyle = action.color;
|
|
ctx.lineWidth = action.brushSize * 2;
|
|
} else {
|
|
ctx.strokeStyle = action.color;
|
|
ctx.lineWidth = action.brushSize;
|
|
}
|
|
|
|
ctx.lineCap = 'round';
|
|
ctx.lineJoin = 'round';
|
|
|
|
if (action.tool === 'arrow') {
|
|
renderArrow(ctx, action, offsetX, offsetY, isComplete);
|
|
} else {
|
|
renderPath(ctx, action, offsetX, offsetY);
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
function renderArrow(
|
|
ctx: CanvasRenderingContext2D,
|
|
action: DrawAction,
|
|
offsetX: number,
|
|
offsetY: number,
|
|
isComplete: boolean,
|
|
) {
|
|
if (action.points.length < 2) return;
|
|
|
|
const firstPoint = action.points[0];
|
|
const lastPoint = action.points[action.points.length - 1];
|
|
|
|
// Draw the path
|
|
ctx.beginPath();
|
|
ctx.moveTo(firstPoint.x + offsetX, firstPoint.y + offsetY);
|
|
|
|
for (let i = 1; i < action.points.length; i++) {
|
|
const point = action.points[i];
|
|
ctx.lineTo(point.x + offsetX, point.y + offsetY);
|
|
}
|
|
ctx.stroke();
|
|
|
|
// Only draw arrowhead when drawing is complete
|
|
if (!isComplete) return;
|
|
|
|
// Calculate angle from a point further back for stable direction that follows the path
|
|
// Use a point 10 steps back, or the first point if path is shorter
|
|
const lookbackIndex = Math.max(0, action.points.length - 10);
|
|
const referencePoint = action.points[lookbackIndex];
|
|
|
|
const angle = Math.atan2(
|
|
lastPoint.y - referencePoint.y,
|
|
lastPoint.x - referencePoint.x,
|
|
);
|
|
|
|
// Animate arrowhead appearance
|
|
const elapsed = action.completedAt ? Date.now() - action.completedAt : ARROW_ANIMATION_DURATION;
|
|
const progress = Math.min(elapsed / ARROW_ANIMATION_DURATION, 1);
|
|
// Ease out cubic for smooth animation
|
|
const easedProgress = 1 - ((1 - progress) ** 3);
|
|
|
|
const headLength = action.brushSize * 3 * easedProgress;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(lastPoint.x + offsetX, lastPoint.y + offsetY);
|
|
ctx.lineTo(
|
|
lastPoint.x + offsetX - headLength * Math.cos(angle - Math.PI / 6),
|
|
lastPoint.y + offsetY - headLength * Math.sin(angle - Math.PI / 6),
|
|
);
|
|
ctx.moveTo(lastPoint.x + offsetX, lastPoint.y + offsetY);
|
|
ctx.lineTo(
|
|
lastPoint.x + offsetX - headLength * Math.cos(angle + Math.PI / 6),
|
|
lastPoint.y + offsetY - headLength * Math.sin(angle + Math.PI / 6),
|
|
);
|
|
ctx.stroke();
|
|
}
|
|
|
|
function renderPath(
|
|
ctx: CanvasRenderingContext2D,
|
|
action: DrawAction,
|
|
offsetX: number,
|
|
offsetY: number,
|
|
) {
|
|
ctx.beginPath();
|
|
const firstPoint = action.points[0];
|
|
ctx.moveTo(firstPoint.x + offsetX, firstPoint.y + offsetY);
|
|
|
|
for (let i = 1; i < action.points.length; i++) {
|
|
const point = action.points[i];
|
|
ctx.lineTo(point.x + offsetX, point.y + offsetY);
|
|
}
|
|
ctx.stroke();
|
|
}
|
|
|
|
export function applyCanvasTransform(
|
|
ctx: CanvasRenderingContext2D,
|
|
image: HTMLImageElement,
|
|
rotation: number,
|
|
flipH: boolean,
|
|
quarterTurns = 0,
|
|
scale = 1,
|
|
) {
|
|
const { width: effW, height: effH } = getEffectiveDimensions(image.width, image.height, quarterTurns);
|
|
ctx.translate(effW / 2, effH / 2);
|
|
ctx.rotate((rotation * Math.PI) / 180);
|
|
ctx.scale(scale * (flipH ? -1 : 1), scale);
|
|
ctx.translate(-image.width / 2, -image.height / 2);
|
|
}
|
|
|
|
export function renderImageToCanvas(
|
|
ctx: CanvasRenderingContext2D,
|
|
img: HTMLImageElement,
|
|
crop: { x: number; y: number; width: number; height: number },
|
|
targetWidth: number,
|
|
targetHeight: number,
|
|
isCropMode: boolean,
|
|
rotation = 0,
|
|
flipH = false,
|
|
quarterTurns = 0,
|
|
scale = 1,
|
|
) {
|
|
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
|
|
|
ctx.save();
|
|
|
|
if (rotation !== 0 || flipH || quarterTurns !== 0 || scale !== 1) {
|
|
applyCanvasTransform(ctx, img, rotation, flipH, quarterTurns, scale);
|
|
}
|
|
|
|
if (isCropMode) {
|
|
ctx.drawImage(img, 0, 0);
|
|
} else {
|
|
ctx.drawImage(
|
|
img,
|
|
crop.x, crop.y, crop.width, crop.height,
|
|
0, 0, targetWidth, targetHeight,
|
|
);
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
export function renderActionsToCanvas(
|
|
ctx: CanvasRenderingContext2D,
|
|
actions: DrawAction[],
|
|
offsetX = 0,
|
|
offsetY = 0,
|
|
currentAction?: DrawAction,
|
|
offscreenWidth?: number,
|
|
offscreenHeight?: number,
|
|
) {
|
|
const hasCurrentAction = currentAction && !actions.includes(currentAction);
|
|
if (actions.length === 0 && !hasCurrentAction) return;
|
|
|
|
const width = offscreenWidth || ctx.canvas.width;
|
|
const height = offscreenHeight || ctx.canvas.height;
|
|
offscreen.width = width;
|
|
offscreen.height = height;
|
|
const offCtx = offscreen.getContext('2d')!;
|
|
offCtx.clearRect(0, 0, width, height);
|
|
|
|
actions.forEach((action) => {
|
|
renderDrawAction(offCtx, action, offsetX, offsetY, true);
|
|
});
|
|
|
|
if (hasCurrentAction) {
|
|
renderDrawAction(offCtx, currentAction, offsetX, offsetY, false);
|
|
}
|
|
|
|
ctx.drawImage(offscreen, 0, 0);
|
|
}
|