Alexander Zinchuk 02a5a2a44f Implement Media Editor (#6658)
Co-authored-by: Shahaf Antwarg <santwarg@gmail.com>
2026-03-05 12:43:29 +01:00

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);
}