Skip to content

Commit b3c7a2d

Browse files
authored
Merge pull request #45 from openpatch/copilot/investigate-image-compression
Add browser-based image compression and 413 error handling
2 parents 9861e63 + 7f6a991 commit b3c7a2d

6 files changed

Lines changed: 4503 additions & 5264 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@learningmap/learningmap": patch
3+
"@learningmap/web-component": patch
4+
---
5+
6+
Add browser-based image compression and 413 error handling
7+
8+
- Compress uploaded images automatically using Canvas API (resize to max 1920x1920px, convert to JPEG at 0.85 quality)
9+
- Add support for JPG, PNG, WebP, and SVG file formats
10+
- Detect HTTP 413 (Payload Too Large) responses and show user-friendly error message
11+
- Add translations for file size error in English and German
12+
- Typical file size reduction: 70-90% for raster images

docs/book/changelog.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,28 @@ If you need a new feature, open an [issue](https://github.com/openpatch/learning
3939
::::
4040
-->
4141

42+
## v0.2.2
43+
44+
::::tabs
45+
46+
:::tab{title="New :rocket:" id="new"}
47+
48+
- Add browser-based image compression for uploaded images to reduce file size by 70-90%
49+
- Add support for WebP image format (JPG, PNG, WebP, and SVG now supported)
50+
51+
:::
52+
53+
:::tab{title="Improved :+1:" id="improved"}
54+
55+
- Automatically resize images to max 1920x1920px while maintaining aspect ratio
56+
- Convert raster images to JPEG format for optimal compression
57+
- Detect HTTP 413 (Payload Too Large) errors and show user-friendly message
58+
- Add translations for file size error in English and German
59+
60+
:::
61+
62+
::::
63+
4264
## v0.2.1
4365

4466
::::tabs

packages/learningmap/src/EditorDrawerImageContent.tsx

Lines changed: 98 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,118 @@ interface Props {
88
language?: string;
99
}
1010

11+
/**
12+
* Compress an image file using the Canvas API.
13+
* Resizes the image to fit within maxWidth x maxHeight while maintaining aspect ratio.
14+
* PNG images are converted to JPEG for better compression.
15+
*
16+
* @param file - The image file to compress
17+
* @param maxWidth - Maximum width in pixels (default: 1920)
18+
* @param maxHeight - Maximum height in pixels (default: 1920)
19+
* @param quality - JPEG compression quality from 0 to 1 (default: 0.85)
20+
* @returns Promise that resolves to a base64 data URL of the compressed image
21+
* @throws Error if image loading or canvas operations fail
22+
*/
23+
async function compressImage(file: File, maxWidth: number = 1920, maxHeight: number = 1920, quality: number = 0.85): Promise<string> {
24+
return new Promise((resolve, reject) => {
25+
const reader = new FileReader();
26+
27+
reader.onload = (e) => {
28+
const img = new Image();
29+
30+
img.onload = () => {
31+
const canvas = document.createElement('canvas');
32+
let width = img.width;
33+
let height = img.height;
34+
35+
// Calculate new dimensions while maintaining aspect ratio
36+
if (width > maxWidth || height > maxHeight) {
37+
const aspectRatio = width / height;
38+
if (width > height) {
39+
width = maxWidth;
40+
height = maxWidth / aspectRatio;
41+
} else {
42+
height = maxHeight;
43+
width = maxHeight * aspectRatio;
44+
}
45+
}
46+
47+
canvas.width = width;
48+
canvas.height = height;
49+
50+
const ctx = canvas.getContext('2d');
51+
if (!ctx) {
52+
reject(new Error('Failed to get canvas context'));
53+
return;
54+
}
55+
56+
ctx.drawImage(img, 0, 0, width, height);
57+
58+
// Convert all raster images to JPEG for consistent compression
59+
// JPEG provides good quality at significantly smaller file sizes
60+
const dataUrl = canvas.toDataURL('image/jpeg', quality);
61+
62+
resolve(dataUrl);
63+
};
64+
65+
img.onerror = () => {
66+
reject(new Error('Failed to load image'));
67+
};
68+
69+
img.src = e.target?.result as string;
70+
};
71+
72+
reader.onerror = () => {
73+
reject(new Error('Failed to read file'));
74+
};
75+
76+
reader.readAsDataURL(file);
77+
});
78+
}
79+
1180
export function EditorDrawerImageContent({ localNode, handleFieldChange, language = "en" }: Props) {
1281
const t = getTranslations(language);
1382

14-
// Convert file to base64 and update node data
83+
// Convert file to base64 with compression
1584
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
1685
const file = e.target.files?.[0];
1786
if (!file) return;
18-
const reader = new FileReader();
19-
reader.onload = () => {
20-
if (typeof reader.result === "string") {
21-
handleFieldChange("data", reader.result);
87+
88+
try {
89+
// For SVG files, don't compress (they're already vector graphics)
90+
if (file.type === 'image/svg+xml') {
91+
const reader = new FileReader();
92+
reader.onload = () => {
93+
if (typeof reader.result === "string") {
94+
handleFieldChange("data", reader.result);
95+
}
96+
};
97+
reader.readAsDataURL(file);
98+
} else {
99+
// Compress JPEG, PNG, and WebP images
100+
const compressedDataUrl = await compressImage(file);
101+
handleFieldChange("data", compressedDataUrl);
22102
}
23-
};
24-
reader.readAsDataURL(file);
103+
} catch (error) {
104+
console.error("Failed to process image:", error);
105+
// Fallback to original file if compression fails
106+
const reader = new FileReader();
107+
reader.onload = () => {
108+
if (typeof reader.result === "string") {
109+
handleFieldChange("data", reader.result);
110+
}
111+
};
112+
reader.readAsDataURL(file);
113+
}
25114
};
26115

27116
return (
28117
<div className="panel-content">
29118
<div className="form-group">
30-
<label>{t.image} (JPG, PNG, SVG)</label>
119+
<label>{t.image} (JPG, PNG, WebP, SVG)</label>
31120
<input
32121
type="file"
33-
accept="image/png,image/jpeg,image/svg+xml"
122+
accept="image/png,image/jpeg,image/webp,image/svg+xml"
34123
onChange={handleFileChange}
35124
/>
36125
</div>

packages/learningmap/src/translations.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export interface Translations {
121121
loadExternalBackupPrompt: string;
122122
emptyMapCannotBeShared: string;
123123
uploadFailed: string;
124+
uploadFileTooLarge: string;
124125
loadFailed: string;
125126

126127
// Share dialog
@@ -341,6 +342,7 @@ const en: Translations = {
341342
"Would you like to back up your current learningmap before loading the external one?",
342343
emptyMapCannotBeShared: "An empty learningmap cannot be shared.",
343344
uploadFailed: "Upload failed. Please try again!",
345+
uploadFileTooLarge: "Sharing bigger files is not allowed. You can still download your learningmap and share it via other means.",
344346
loadFailed: "Failed to load. Please check the URL.",
345347

346348
// Share dialog
@@ -566,6 +568,7 @@ const de: Translations = {
566568
emptyMapCannotBeShared: "Eine leere Learningmap kann nicht geteilt werden.",
567569
uploadFailed:
568570
"Beim Upload ist was schief gelaufen. Bitte versuche es erneut!",
571+
uploadFileTooLarge: "Das Teilen größerer Dateien ist nicht erlaubt. Du kannst deine Learningmap weiterhin herunterladen und über andere Wege teilen.",
569572
loadFailed: "Beim Laden ist etwas schief gegangen. Bitte überprüfe die URL.",
570573

571574
// Share dialog

packages/learningmap/src/useJsonStore.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,26 @@ export const useJsonStore = () => {
3636
},
3737
body: JSON.stringify(roadmapData),
3838
})
39-
.then((r) => r.json())
39+
.then((r) => {
40+
if (r.status === 413) {
41+
alert(t.uploadFileTooLarge);
42+
throw new Error("Payload too large");
43+
}
44+
if (!r.ok) {
45+
throw new Error(`HTTP error! status: ${r.status}`);
46+
}
47+
return r.json();
48+
})
4049
.then((json) => {
4150
const link = window.location.origin + "#json=" + json.id;
4251
setShareLink(link);
4352
setShareDialogOpen(true);
4453
})
45-
.catch(() => {
46-
alert(t.uploadFailed);
54+
.catch((error) => {
55+
// Don't show generic error if we already showed the 413 error
56+
if (error.message !== "Payload too large") {
57+
alert(t.uploadFailed);
58+
}
4759
});
4860
}, [getRoadmapData, jsonStore, t, setShareLink, setShareDialogOpen]);
4961

0 commit comments

Comments
 (0)