Forráskód Böngészése

fix: load image synchronously for canvas crop — avoids drawImage type error

Load image via FileReader + new Image() directly in handleFileChange instead
of relying on img.onLoad event from the preview <img> element. This avoids
the React synthetic event type mismatch and ensures originalImageRef is
always set before handleConfirm runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude Dev 1 hónapja
szülő
commit
7d694d1fe4
1 módosított fájl, 39 hozzáadás és 39 törlés
  1. 39 39
      src/components/settings/ProfilePictureUpload.tsx

+ 39 - 39
src/components/settings/ProfilePictureUpload.tsx

@@ -42,24 +42,22 @@ export function ProfilePictureUpload({ currentAvatarUrl, userName, token, onUplo
     reader.onload = (ev) => {
       const src = ev.target?.result as string;
       setPreview(src);
-      setCropData(null);
+
+      // Load image immediately so it's ready for handleConfirm
+      const img = new Image();
+      img.onload = () => {
+        originalImageRef.current = img;
+        const minDim = Math.min(img.naturalWidth, img.naturalHeight);
+        const cropSize = Math.floor(minDim * 0.8);
+        const x = Math.floor((img.naturalWidth - cropSize) / 2);
+        const y = Math.floor((img.naturalHeight - cropSize) / 2);
+        setCropData({ x, y, size: cropSize });
+      };
+      img.src = src;
     };
     reader.readAsDataURL(file);
   };
 
-  // ── Load image for crop interaction ──────────────────────────────────────
-  const handleImageLoad = (e: React.ReactElement<HTMLImageElement>) => {
-    const img = e as unknown as HTMLImageElement;
-    if (!img) return;
-    originalImageRef.current = img;
-    // Auto-center square crop at 80% of the smaller dimension
-    const minDim = Math.min(img.naturalWidth, img.naturalHeight);
-    const cropSize = Math.floor(minDim * 0.8);
-    const x = Math.floor((img.naturalWidth - cropSize) / 2);
-    const y = Math.floor((img.naturalHeight - cropSize) / 2);
-    setCropData({ x, y, size: cropSize });
-  };
-
   // ── Drag to reposition crop ────────────────────────────────────────────
   const dragRef = useRef<{ startX: number; startY: number; startCrop: CropRect } | null>(null);
 
@@ -91,7 +89,7 @@ export function ProfilePictureUpload({ currentAvatarUrl, userName, token, onUplo
 
   // ── Process & upload ──────────────────────────────────────────────────────
   const handleConfirm = async () => {
-    if (!preview || !cropData) return;
+    if (!preview || !cropData || !originalImageRef.current) return;
     setUploading(true);
     setError(null);
     try {
@@ -100,33 +98,28 @@ export function ProfilePictureUpload({ currentAvatarUrl, userName, token, onUplo
       canvas.width = 400;
       canvas.height = 400;
 
-      const img = originalImageRef.current!;
       ctx.drawImage(
-        img,
+        originalImageRef.current,
         cropData.x, cropData.y, cropData.size, cropData.size,
         0, 0, 400, 400
       );
 
       // Compress to JPEG at quality that yields ~30KB
-      const blob: Blob = await new Promise(resolve =>
-        canvas.toBlob(b => resolve(b!), 'image/jpeg', 0.75)
+      const blob: Blob = await new Promise((resolve) =>
+        canvas.toBlob((b) => resolve(b!), 'image/jpeg', 0.75)
       );
 
       // If still > 35KB, reduce quality iteratively
       let quality = 0.75;
-      while (blob.size > 35 * 1024 && quality > 0.1) {
+      let currentBlob = blob;
+      while (currentBlob.size > 35 * 1024 && quality > 0.1) {
         quality -= 0.1;
-        const b: Blob = await new Promise(resolve =>
-          canvas.toBlob(b => resolve(b!), 'image/jpeg', quality)
+        currentBlob = await new Promise((resolve) =>
+          canvas.toBlob((b) => resolve(b!), 'image/jpeg', quality)
         );
-        if (b.size < blob.size) {
-          blob as unknown as { size: number }; // keep track
-          const newBlob = b as unknown as Blob & { size: number };
-          void newBlob; // just to use the variable
-        }
       }
 
-      const file = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
+      const file = new File([currentBlob], 'avatar.jpg', { type: 'image/jpeg' });
       const { user } = await usersApi.uploadAvatar(token, file);
       onUploaded(user.avatarUrl ?? '');
       setPreview(null);
@@ -168,7 +161,7 @@ export function ProfilePictureUpload({ currentAvatarUrl, userName, token, onUplo
               className="w-16 h-16 rounded-full flex items-center justify-center text-lg font-bold"
               style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC', border: '2px solid rgba(99,102,241,0.25)' }}
             >
-              {userName.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase()}
+              {userName.split(' ').map((n) => n[0]).slice(0, 2).join('').toUpperCase()}
             </div>
           )}
           <button
@@ -201,31 +194,30 @@ export function ProfilePictureUpload({ currentAvatarUrl, userName, token, onUplo
               alt="Crop preview"
               className="w-full h-full object-contain"
               draggable={false}
-              onLoad={(e) => handleImageLoad(e as unknown as React.ReactElement<HTMLImageElement>)}
             />
 
             {/* Dimmed overlay */}
-            {cropData && (
+            {cropData && originalImageRef.current && (
               <>
                 {/* Top */}
                 <div className="absolute inset-0" style={{
                   background: 'rgba(0,0,0,0.5)',
-                  clipPath: `polygon(0 0, 100% 0, 100% ${(cropData.y / (originalImageRef.current?.naturalHeight || 1)) * 100}%, 0 ${(cropData.y / (originalImageRef.current?.naturalHeight || 1)) * 100}%)`,
+                  clipPath: `polygon(0 0, 100% 0, 100% ${(cropData.y / originalImageRef.current.naturalHeight) * 100}%, 0 ${(cropData.y / originalImageRef.current.naturalHeight) * 100}%)`,
                 }} />
                 {/* Bottom */}
                 <div className="absolute inset-0" style={{
                   background: 'rgba(0,0,0,0.5)',
-                  clipPath: `polygon(0 ${((cropData.y + cropData.size) / (originalImageRef.current?.naturalHeight || 1)) * 100}%, 100% ${((cropData.y + cropData.size) / (originalImageRef.current?.naturalHeight || 1)) * 100}%, 100% 100%, 0 100%)`,
+                  clipPath: `polygon(0 ${((cropData.y + cropData.size) / originalImageRef.current.naturalHeight) * 100}%, 100% ${((cropData.y + cropData.size) / originalImageRef.current.naturalHeight) * 100}%, 100% 100%, 0 100%)`,
                 }} />
                 {/* Left */}
                 <div className="absolute inset-0" style={{
                   background: 'rgba(0,0,0,0.5)',
-                  clipPath: `polygon(0 0, ${(cropData.x / (originalImageRef.current?.naturalWidth || 1)) * 100}% 0, ${(cropData.x / (originalImageRef.current?.naturalWidth || 1)) * 100}% ${((cropData.y + cropData.size) / (originalImageRef.current?.naturalHeight || 1)) * 100}%, 0 ${((cropData.y + cropData.size) / (originalImageRef.current?.naturalHeight || 1)) * 100}%)`,
+                  clipPath: `polygon(0 0, ${(cropData.x / originalImageRef.current.naturalWidth) * 100}% 0, ${(cropData.x / originalImageRef.current.naturalWidth) * 100}% ${((cropData.y + cropData.size) / originalImageRef.current.naturalHeight) * 100}%, 0 ${((cropData.y + cropData.size) / originalImageRef.current.naturalHeight) * 100}%)`,
                 }} />
                 {/* Right */}
                 <div className="absolute inset-0" style={{
                   background: 'rgba(0,0,0,0.5)',
-                  clipPath: `polygon(${(cropData.x + cropData.size) / (originalImageRef.current?.naturalWidth || 1) * 100}% 0, 100% 0, 100% 100%, ${(cropData.x + cropData.size) / (originalImageRef.current?.naturalWidth || 1) * 100}% 100%)`,
+                  clipPath: `polygon(${(cropData.x + cropData.size) / originalImageRef.current.naturalWidth * 100}% 0, 100% 0, 100% 100%, ${(cropData.x + cropData.size) / originalImageRef.current.naturalWidth * 100}% 100%)`,
                 }} />
               </>
             )}
@@ -245,11 +237,19 @@ export function ProfilePictureUpload({ currentAvatarUrl, userName, token, onUplo
               >
                 {/* Grid lines */}
                 <div className="absolute inset-0 grid grid-rows-3 grid-cols-3 pointer-events-none">
-                  {[0,1,2,3].map(i => (
-                    <div key={`h${i}`} className="border-b border-white/20" style={{ borderBottomWidth: i < 3 ? '1px' : 0, borderColor: 'rgba(255,255,255,0.2)' }} />
+                  {[0, 1, 2, 3].map((i) => (
+                    <div
+                      key={`h${i}`}
+                      className="border-b border-white/20"
+                      style={{ borderBottomWidth: i < 3 ? '1px' : 0 }}
+                    />
                   ))}
-                  {[0,1,2,3].map(i => (
-                    <div key={`v${i}`} className="border-r border-white/20" style={{ borderRightWidth: i < 3 ? '1px' : 0, borderColor: 'rgba(255,255,255,0.2)' }} />
+                  {[0, 1, 2, 3].map((i) => (
+                    <div
+                      key={`v${i}`}
+                      className="border-r border-white/20"
+                      style={{ borderRightWidth: i < 3 ? '1px' : 0 }}
+                    />
                   ))}
                 </div>
               </div>