Files
multica/packages/views/common/avatar-crop.ts
Naiyuan Qing c6783efd88 feat(views): unify avatar upload with crop editing (#5074)
* feat(views): unify avatar upload with crop editing across web/desktop

Add a shared AvatarUploadControl + AvatarCropDialog used by the user,
workspace, agent, and squad avatar entry points. Cropping (pan/zoom, fixed
1:1) and compression run client-side on canvas; the existing /api/upload-file
+ avatar_url chain is reused unchanged (no backend/API/DB changes). This
collapses four hand-rolled upload buttons into one control and removes
AvatarPicker.

Also make the shared display avatar treat all non-human actors (agent, squad,
system) as rounded squares — completing the "circles are for humans"
convention the editors already assumed, so display and editors agree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(views): rebuild avatar cropper to the "Edit avatar" reference form

Replace the hand-rolled canvas cropper with react-easy-crop to match the
requested design: full-bleed image with a dimmed overlay outside a bright
crop window, a rotate control, a zoom slider flanked by −/+, and a
Reset / Cancel / Save footer. Round window for people, rounded-square for
non-human actors; output stays a 512px square (webp, jpeg fallback) through
the same upload/avatar_url chain.

avatar-crop.ts keeps the encode pipeline and gains rotation-aware
getCroppedAvatarBlob; the interactive geometry now lives in react-easy-crop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(views): round square avatar crop frame

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 20:27:33 +08:00

151 lines
4.6 KiB
TypeScript

// Canvas rendering + encoding for the shared avatar cropper.
//
// react-easy-crop owns the interactive geometry (pan / zoom / rotate) and hands
// back the crop rectangle in source-image pixels via `onCropComplete`. This
// module turns that rectangle into a fixed-size square, compressed avatar file.
/** Square side of the encoded avatar. Avatars never need the original bitmap. */
export const AVATAR_OUTPUT_SIZE = 512;
const AVATAR_QUALITY = 0.85;
/** react-easy-crop's `croppedAreaPixels` shape (source-image px). */
export interface PixelCrop {
x: number;
y: number;
width: number;
height: number;
}
let webpEncodeSupport: boolean | null = null;
/**
* Whether the browser can *encode* WebP via canvas. Safari < 17 decodes WebP
* but cannot encode it, silently emitting PNG from toDataURL — so we probe the
* data URL's mime rather than assuming.
*/
export function supportsWebpEncode(): boolean {
if (webpEncodeSupport !== null) return webpEncodeSupport;
try {
const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
webpEncodeSupport = canvas
.toDataURL("image/webp")
.startsWith("data:image/webp");
} catch {
webpEncodeSupport = false;
}
return webpEncodeSupport;
}
/** Preferred output type: WebP (keeps alpha, smaller) with a JPEG fallback. */
export function pickOutputType(): { type: string; quality: number } {
return supportsWebpEncode()
? { type: "image/webp", quality: AVATAR_QUALITY }
: { type: "image/jpeg", quality: AVATAR_QUALITY };
}
/** Wrap an encoded blob in a File, swapping the source extension for the output's. */
export function blobToAvatarFile(
blob: Blob,
sourceName: string,
type: string,
): File {
const ext = type === "image/webp" ? "webp" : type === "image/png" ? "png" : "jpg";
const base = sourceName.replace(/\.[^./\\]+$/, "") || "avatar";
return new File([blob], `${base}.${ext}`, { type });
}
function canvasToBlob(
canvas: HTMLCanvasElement,
type: string,
quality: number,
): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("Canvas is empty"))),
type,
quality,
);
});
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener("load", () => resolve(image));
image.addEventListener("error", () => reject(new Error("Could not load image")));
image.src = src;
});
}
function toRadians(degrees: number): number {
return (degrees * Math.PI) / 180;
}
/** Bounding box of an image after rotation, in px. */
function rotatedBoundingBox(width: number, height: number, degrees: number) {
const rad = toRadians(degrees);
return {
width: Math.abs(Math.cos(rad) * width) + Math.abs(Math.sin(rad) * height),
height: Math.abs(Math.sin(rad) * width) + Math.abs(Math.cos(rad) * height),
};
}
export interface RenderOptions {
output: number;
type: string;
quality: number;
/** Fill color drawn behind the image, for opaque formats (JPEG). */
background?: string;
}
/**
* Draw the (possibly rotated) crop region into a square canvas and encode it.
* `pixelCrop` is react-easy-crop's `croppedAreaPixels`, expressed in the
* rotated-image bounding-box coordinate space — so we first rotate the whole
* image into a bounding-box canvas, then sample the crop rect from it.
*/
export async function getCroppedAvatarBlob(
imageSrc: string,
pixelCrop: PixelCrop,
rotation: number,
options: RenderOptions,
): Promise<Blob> {
const image = await loadImage(imageSrc);
const bBox = rotatedBoundingBox(image.width, image.height, rotation);
const rotated = document.createElement("canvas");
rotated.width = Math.round(bBox.width);
rotated.height = Math.round(bBox.height);
const rctx = rotated.getContext("2d");
if (!rctx) throw new Error("Canvas 2D context unavailable");
rctx.translate(rotated.width / 2, rotated.height / 2);
rctx.rotate(toRadians(rotation));
rctx.drawImage(image, -image.width / 2, -image.height / 2);
const out = document.createElement("canvas");
out.width = options.output;
out.height = options.output;
const octx = out.getContext("2d");
if (!octx) throw new Error("Canvas 2D context unavailable");
octx.imageSmoothingQuality = "high";
if (options.background) {
octx.fillStyle = options.background;
octx.fillRect(0, 0, options.output, options.output);
}
octx.drawImage(
rotated,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
options.output,
options.output,
);
return canvasToBlob(out, options.type, options.quality);
}