193 lines
6.8 KiB
JavaScript
193 lines
6.8 KiB
JavaScript
/** @odoo-module **/
|
|
/**
|
|
* Image compression for file uploads on Odoo.
|
|
*
|
|
* Problem: On iPhone, selecting 4+ photos (5-15MB each) causes the
|
|
* browser tab to crash because Odoo converts each to a base64 data URL
|
|
* before uploading. 7 photos = 50-100MB of strings in memory.
|
|
*
|
|
* Solution: Intercept at the FileUploader level, compress each image
|
|
* via Canvas BEFORE the data URL conversion. A 5MB photo becomes ~300KB.
|
|
*
|
|
* The FileUploader.onFileChange is completely overridden (not wrapped)
|
|
* to avoid any DataTransfer API issues on iPhone Safari.
|
|
*/
|
|
import { AttachmentUploadService } from "@mail/core/common/attachment_upload_service";
|
|
import { FileUploader } from "@web/views/fields/file_handler";
|
|
import { patch } from "@web/core/utils/patch";
|
|
import { getDataURLFromFile } from "@web/core/utils/urls";
|
|
import { checkFileSize } from "@web/core/utils/files";
|
|
|
|
const IMAGE_TYPES = new Set([
|
|
"image/jpeg", "image/png", "image/webp", "image/bmp",
|
|
"image/heic", "image/heif",
|
|
]);
|
|
const MAX_DIMENSION = 1280; // Conservative for mobile memory
|
|
const JPEG_QUALITY = 0.80;
|
|
const SKIP_THRESHOLD = 500 * 1024; // 500KB
|
|
|
|
/**
|
|
* Compress an image File via Canvas API.
|
|
* Returns the original file if anything fails.
|
|
*/
|
|
function compressImageFile(file) {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const img = new Image();
|
|
const objectUrl = URL.createObjectURL(file);
|
|
const cleanup = () => {
|
|
try { URL.revokeObjectURL(objectUrl); } catch(e) {}
|
|
try { img.src = ""; } catch(e) {}
|
|
};
|
|
const timeout = setTimeout(() => {
|
|
cleanup();
|
|
resolve(file); // Timeout fallback after 10s
|
|
}, 10000);
|
|
img.onload = () => {
|
|
try {
|
|
clearTimeout(timeout);
|
|
let w = img.naturalWidth || img.width;
|
|
let h = img.naturalHeight || img.height;
|
|
if (w > MAX_DIMENSION || h > MAX_DIMENSION) {
|
|
const ratio = Math.min(MAX_DIMENSION / w, MAX_DIMENSION / h);
|
|
w = Math.round(w * ratio);
|
|
h = Math.round(h * ratio);
|
|
}
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = w;
|
|
canvas.height = h;
|
|
canvas.getContext("2d").drawImage(img, 0, 0, w, h);
|
|
canvas.toBlob(
|
|
(blob) => {
|
|
cleanup();
|
|
canvas.width = 0;
|
|
canvas.height = 0;
|
|
if (!blob) { resolve(file); return; }
|
|
const name = file.name.replace(/\.[^.]+$/, "") + ".jpg";
|
|
resolve(new File([blob], name, {
|
|
type: "image/jpeg",
|
|
lastModified: file.lastModified,
|
|
}));
|
|
},
|
|
"image/jpeg",
|
|
JPEG_QUALITY
|
|
);
|
|
} catch (e) {
|
|
clearTimeout(timeout);
|
|
cleanup();
|
|
resolve(file);
|
|
}
|
|
};
|
|
img.onerror = () => {
|
|
clearTimeout(timeout);
|
|
cleanup();
|
|
resolve(file);
|
|
};
|
|
img.src = objectUrl;
|
|
} catch (e) {
|
|
resolve(file);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Override FileUploader.onFileChange to compress images before
|
|
* converting to data URLs. This prevents the massive memory spike
|
|
* that crashes mobile browsers.
|
|
*
|
|
* We re-implement onFileChange instead of wrapping it to avoid
|
|
* DataTransfer API issues on iPhone Safari.
|
|
*/
|
|
patch(FileUploader.prototype, {
|
|
async onFileChange(ev) {
|
|
const rawFiles = ev.target?.files;
|
|
if (!rawFiles || !rawFiles.length) {
|
|
return;
|
|
}
|
|
|
|
// Check if any file needs compression
|
|
let hasLargeImages = false;
|
|
for (const f of rawFiles) {
|
|
if (IMAGE_TYPES.has(f.type) && f.size > SKIP_THRESHOLD) {
|
|
hasLargeImages = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// No large images -- use standard Odoo behavior
|
|
if (!hasLargeImages) {
|
|
return super.onFileChange(ev);
|
|
}
|
|
|
|
// Process files one at a time with compression
|
|
const files = [...rawFiles].filter((f) => this.validFileType(f));
|
|
const target = ev.target;
|
|
|
|
for (const file of files) {
|
|
let processedFile = file;
|
|
|
|
// Compress large images
|
|
if (IMAGE_TYPES.has(file.type) && file.size > SKIP_THRESHOLD) {
|
|
try {
|
|
processedFile = await compressImageFile(file);
|
|
} catch (e) {
|
|
processedFile = file; // fallback to original
|
|
}
|
|
}
|
|
|
|
// Size check
|
|
if (this.props.checkSize && !checkFileSize(processedFile.size, this.notification)) {
|
|
continue;
|
|
}
|
|
|
|
this.state.isUploading = true;
|
|
try {
|
|
const data = await getDataURLFromFile(processedFile);
|
|
if (!processedFile.size) {
|
|
this.notification.add(
|
|
`There was a problem while uploading: ${processedFile.name}`,
|
|
{ type: "danger" }
|
|
);
|
|
continue;
|
|
}
|
|
await this.props.onUploaded({
|
|
name: processedFile.name,
|
|
size: processedFile.size,
|
|
type: processedFile.type,
|
|
data: data.split(",")[1],
|
|
objectUrl:
|
|
processedFile.type === "application/pdf"
|
|
? URL.createObjectURL(processedFile)
|
|
: null,
|
|
});
|
|
} catch (e) {
|
|
// Skip this file on error, continue with others
|
|
} finally {
|
|
this.state.isUploading = false;
|
|
}
|
|
}
|
|
|
|
// Reset input so same file can be re-selected
|
|
target.value = null;
|
|
if (this.props.multiUpload && this.props.onUploadComplete) {
|
|
this.props.onUploadComplete({});
|
|
}
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Safety net for drag-drop and paste uploads that bypass FileUploader.
|
|
*/
|
|
patch(AttachmentUploadService.prototype, {
|
|
async upload(thread, composer, file, options) {
|
|
if (file && IMAGE_TYPES.has(file.type) && file.size > SKIP_THRESHOLD) {
|
|
try {
|
|
file = await compressImageFile(file);
|
|
} catch (e) {
|
|
// Use original file
|
|
}
|
|
}
|
|
return super.upload(thread, composer, file, options);
|
|
},
|
|
});
|