mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: replace sharp with client-side icon resize (#1951)
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
"@tanstack/react-query": "^5.90.17",
|
||||
"alfaaz": "^1.1.0",
|
||||
"axios": "^1.13.5",
|
||||
"blueimp-load-image": "^5.16.0",
|
||||
"clsx": "^2.1.1",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
@@ -59,6 +60,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.16.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.62.1",
|
||||
"@types/blueimp-load-image": "^5.16.0",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/katex": "^0.16.7",
|
||||
|
||||
@@ -1,20 +1,62 @@
|
||||
import api from "@/lib/api-client";
|
||||
import loadImage from "blueimp-load-image";
|
||||
import {
|
||||
AvatarIconType,
|
||||
IAttachment,
|
||||
} from "@/features/attachments/types/attachment.types.ts";
|
||||
|
||||
async function compressAndResizeIcon(
|
||||
file: File,
|
||||
type: AvatarIconType,
|
||||
): Promise<File> {
|
||||
const isPng = file.type === "image/png";
|
||||
|
||||
const { image: canvas } = await loadImage(file, {
|
||||
maxWidth: 300,
|
||||
maxHeight: 300,
|
||||
canvas: true,
|
||||
orientation: true,
|
||||
imageSmoothingQuality: "high",
|
||||
});
|
||||
|
||||
if (type === AvatarIconType.AVATAR || !isPng) {
|
||||
const ctx = (canvas as HTMLCanvasElement).getContext("2d")!;
|
||||
ctx.globalCompositeOperation = "destination-over";
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
}
|
||||
|
||||
const outputType = isPng ? "image/png" : "image/jpeg";
|
||||
|
||||
return new Promise<File>((resolve, reject) => {
|
||||
(canvas as HTMLCanvasElement).toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("Failed to compress image"));
|
||||
return;
|
||||
}
|
||||
resolve(new File([blob], file.name, { type: outputType }));
|
||||
},
|
||||
outputType,
|
||||
isPng ? undefined : 0.85,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadIcon(
|
||||
file: File,
|
||||
type: AvatarIconType,
|
||||
spaceId?: string,
|
||||
): Promise<IAttachment> {
|
||||
const processed = await compressAndResizeIcon(file, type);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("type", type);
|
||||
if (spaceId) {
|
||||
formData.append("spaceId", spaceId);
|
||||
}
|
||||
formData.append("image", file);
|
||||
formData.append("image", processed);
|
||||
|
||||
return await api.post("/attachments/upload-image", formData, {
|
||||
headers: {
|
||||
|
||||
@@ -100,7 +100,6 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"sanitize-filename-ts": "1.0.2",
|
||||
"sharp": "0.34.3",
|
||||
"socket.io": "^4.8.3",
|
||||
"stripe": "^17.5.0",
|
||||
"tmp-promise": "^3.0.3",
|
||||
|
||||
@@ -2,7 +2,6 @@ import { MultipartFile } from '@fastify/multipart';
|
||||
import * as path from 'path';
|
||||
import { AttachmentType } from './attachment.constants';
|
||||
import { sanitizeFileName } from '../../common/helpers';
|
||||
import * as sharp from 'sharp';
|
||||
|
||||
export interface PreparedFile {
|
||||
buffer?: Buffer;
|
||||
@@ -77,51 +76,3 @@ export function getAttachmentFolderPath(
|
||||
}
|
||||
|
||||
export const validAttachmentTypes = Object.values(AttachmentType);
|
||||
|
||||
export async function compressAndResizeIcon(
|
||||
buffer: Buffer,
|
||||
attachmentType?: AttachmentType,
|
||||
): Promise<Buffer> {
|
||||
try {
|
||||
let sharpInstance = sharp(buffer);
|
||||
const metadata = await sharpInstance.metadata();
|
||||
|
||||
const targetWidth = 300;
|
||||
const targetHeight = 300;
|
||||
|
||||
// Only resize if image is larger than target dimensions
|
||||
if (metadata.width > targetWidth || metadata.height > targetHeight) {
|
||||
sharpInstance = sharpInstance.resize(targetWidth, targetHeight, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle based on original format
|
||||
if (metadata.format === 'png') {
|
||||
// Only flatten avatars to remove transparency
|
||||
if (attachmentType === AttachmentType.Avatar) {
|
||||
sharpInstance = sharpInstance.flatten({
|
||||
background: { r: 255, g: 255, b: 255 },
|
||||
});
|
||||
}
|
||||
|
||||
return await sharpInstance
|
||||
.png({
|
||||
quality: 85,
|
||||
compressionLevel: 6,
|
||||
})
|
||||
.toBuffer();
|
||||
} else {
|
||||
return await sharpInstance
|
||||
.jpeg({
|
||||
quality: 85,
|
||||
progressive: true,
|
||||
mozjpeg: true,
|
||||
})
|
||||
.toBuffer();
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Readable } from 'stream';
|
||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||
import { MultipartFile } from '@fastify/multipart';
|
||||
import {
|
||||
compressAndResizeIcon,
|
||||
getAttachmentFolderPath,
|
||||
PreparedFile,
|
||||
prepareFile,
|
||||
@@ -154,12 +153,6 @@ export class AttachmentService {
|
||||
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
||||
validateFileType(preparedFile.fileExtension, validImageExtensions);
|
||||
|
||||
const processedBuffer = await compressAndResizeIcon(
|
||||
preparedFile.buffer,
|
||||
type,
|
||||
);
|
||||
preparedFile.buffer = processedBuffer;
|
||||
preparedFile.fileSize = processedBuffer.length;
|
||||
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
|
||||
|
||||
const filePath = `${getAttachmentFolderPath(type, workspaceId)}/${preparedFile.fileName}`;
|
||||
|
||||
Reference in New Issue
Block a user