diff --git a/.changeset/image-compression.md b/.changeset/image-compression.md new file mode 100644 index 000000000..fc5773d66 --- /dev/null +++ b/.changeset/image-compression.md @@ -0,0 +1,22 @@ +--- +'@keystatic/core': minor +--- + +Add optional client-side image compression to `fields.image()` + +Images can now be automatically compressed before saving using the new `compression` option: + +```ts +fields.image({ + label: 'Cover Image', + compression: { + maxWidth: 2000, // px + maxHeight: 2000, // px + maxFileSize: 1048576, // 1MB in bytes + quality: 0.8, // 0-1 for lossy formats + format: 'preserve', // 'preserve' | 'webp' | 'jpeg' + }, +}) +``` + +When compression is enabled, the UI displays the original and compressed file sizes after upload. diff --git a/docs/src/components/fields/image.tsx b/docs/src/components/fields/image.tsx index 4d35964d7..73d5afe01 100644 --- a/docs/src/components/fields/image.tsx +++ b/docs/src/components/fields/image.tsx @@ -22,6 +22,7 @@ export const ImageFieldDemo = () => { autoFocus={false} forceValidation={false} transformFilename={undefined} + compression={undefined} /> ); diff --git a/packages/keystatic/package.json b/packages/keystatic/package.json index 7c591e93a..e805cbe76 100644 --- a/packages/keystatic/package.json +++ b/packages/keystatic/package.json @@ -139,6 +139,7 @@ "@urql/exchange-auth": "^2.2.0", "@urql/exchange-graphcache": "^7.1.2", "@urql/exchange-persisted": "^4.3.0", + "browser-image-compression": "^2.0.2", "cookie": "^1.0.0", "emery": "^1.4.1", "escape-string-regexp": "^4.0.0", diff --git a/packages/keystatic/src/form/fields/image/compress.test.ts b/packages/keystatic/src/form/fields/image/compress.test.ts new file mode 100644 index 000000000..87fee87c1 --- /dev/null +++ b/packages/keystatic/src/form/fields/image/compress.test.ts @@ -0,0 +1,29 @@ +/** + * @jest-environment jsdom + */ +import { describe, test, expect } from '@jest/globals'; +import { formatBytes } from './compress'; + +describe('formatBytes', () => { + test('formats 0 bytes', () => { + expect(formatBytes(0)).toBe('0 B'); + }); + + test('formats bytes', () => { + expect(formatBytes(500)).toBe('500 B'); + }); + + test('formats kilobytes', () => { + expect(formatBytes(1024)).toBe('1 KB'); + expect(formatBytes(1536)).toBe('1.5 KB'); + }); + + test('formats megabytes', () => { + expect(formatBytes(1048576)).toBe('1 MB'); + expect(formatBytes(2621440)).toBe('2.5 MB'); + }); + + test('formats gigabytes', () => { + expect(formatBytes(1073741824)).toBe('1 GB'); + }); +}); diff --git a/packages/keystatic/src/form/fields/image/compress.ts b/packages/keystatic/src/form/fields/image/compress.ts new file mode 100644 index 000000000..7820905d8 --- /dev/null +++ b/packages/keystatic/src/form/fields/image/compress.ts @@ -0,0 +1,148 @@ +import imageCompression from 'browser-image-compression'; + +export type ImageCompressionConfig = { + /** Maximum width in pixels. Image will be resized proportionally. */ + maxWidth?: number; + /** Maximum height in pixels. Image will be resized proportionally. */ + maxHeight?: number; + /** Maximum file size in bytes (e.g., 1048576 for 1MB). */ + maxFileSize?: number; + /** Quality for lossy formats (0-1). Default: 0.8 */ + quality?: number; + /** Output format. 'preserve' keeps original, 'webp' or 'jpeg' converts. Default: 'preserve' */ + format?: 'preserve' | 'webp' | 'jpeg'; +}; + +export type CompressionResult = { + data: Uint8Array; + extension: string; + originalSize: number; + compressedSize: number; +}; + +function getExtensionFromMimeType(mimeType: string): string { + const map: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif', + 'image/svg+xml': 'svg', + }; + return map[mimeType] || 'jpg'; +} + +function getMimeTypeFromExtension(extension: string): string { + const map: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', + gif: 'image/gif', + svg: 'image/svg+xml', + }; + return map[extension.toLowerCase()] || 'image/jpeg'; +} + +function getOutputMimeType( + originalExtension: string, + format: ImageCompressionConfig['format'] +): string { + if (!format || format === 'preserve') { + return getMimeTypeFromExtension(originalExtension); + } + return format === 'webp' ? 'image/webp' : 'image/jpeg'; +} + +/** + * Compresses an image file according to the provided configuration. + * Returns the original file data if compression fails or produces a larger file. + */ +export async function compressImage( + file: File, + config: ImageCompressionConfig +): Promise { + const originalSize = file.size; + const originalExtension = + file.name.match(/\.([^.]+)$/)?.[1]?.toLowerCase() || 'jpg'; + + // Skip SVG (vector) and GIF (may lose animation) files + if ( + originalExtension === 'svg' || + originalExtension === 'gif' || + file.type === 'image/svg+xml' || + file.type === 'image/gif' + ) { + const data = new Uint8Array(await file.arrayBuffer()); + return { + data, + extension: originalExtension, + originalSize, + compressedSize: originalSize, + }; + } + + const outputMimeType = getOutputMimeType(originalExtension, config.format); + const outputExtension = getExtensionFromMimeType(outputMimeType); + + try { + // Use the smaller of maxWidth/maxHeight to ensure both constraints are satisfied + // browser-image-compression only supports a single maxWidthOrHeight value + const maxDimensions = [config.maxWidth, config.maxHeight].filter( + (v): v is number => v !== undefined && v > 0 + ); + const maxWidthOrHeight = + maxDimensions.length > 0 ? Math.min(...maxDimensions) : undefined; + + const options: Parameters[1] = { + maxSizeMB: config.maxFileSize + ? config.maxFileSize / 1024 / 1024 + : undefined, + maxWidthOrHeight, + initialQuality: config.quality ?? 0.8, + useWebWorker: true, + fileType: outputMimeType, + }; + + const compressedFile = await imageCompression(file, options); + const compressedSize = compressedFile.size; + + // If compression made the file larger, return original + if (compressedSize >= originalSize) { + const data = new Uint8Array(await file.arrayBuffer()); + return { + data, + extension: originalExtension, + originalSize, + compressedSize: originalSize, + }; + } + + const data = new Uint8Array(await compressedFile.arrayBuffer()); + return { + data, + extension: outputExtension, + originalSize, + compressedSize, + }; + } catch { + // On any error, return original file (silent fallback) + const data = new Uint8Array(await file.arrayBuffer()); + return { + data, + extension: originalExtension, + originalSize, + compressedSize: originalSize, + }; + } +} + +/** + * Format bytes as human-readable string (e.g., "1.5 MB") + */ +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} diff --git a/packages/keystatic/src/form/fields/image/index.tsx b/packages/keystatic/src/form/fields/image/index.tsx index 759e534f2..a1b5b8ba7 100644 --- a/packages/keystatic/src/form/fields/image/index.tsx +++ b/packages/keystatic/src/form/fields/image/index.tsx @@ -4,6 +4,9 @@ import { FieldDataError } from '../error'; import { RequiredValidation, assertRequired } from '../utils'; import { getSrcPrefix } from './getSrcPrefix'; import { ImageFieldInput } from '#field-ui/image'; +import type { ImageCompressionConfig } from './compress'; + +export type { ImageCompressionConfig } from './compress'; export function image({ label, @@ -12,6 +15,7 @@ export function image({ description, publicPath, transformFilename, + compression, }: { label: string; directory?: string; @@ -24,6 +28,11 @@ export function image({ * When used outside of editor fields, this function will **not** be used. Instead only the extension of the uploaded file is used and the start of the filename is based on the field key. */ transformFilename?: (originalFilename: string) => string; + /** + * Optional client-side image compression configuration. + * When provided, images will be compressed before saving. + */ + compression?: ImageCompressionConfig; } & RequiredValidation): AssetFormField< { data: Uint8Array; extension: string; filename: string } | null, | { data: Uint8Array; extension: string; filename: string } @@ -41,6 +50,7 @@ export function image({ description={description} validation={validation} transformFilename={transformFilename} + compression={compression} {...props} /> ); diff --git a/packages/keystatic/src/form/fields/image/ui.tsx b/packages/keystatic/src/form/fields/image/ui.tsx index 50fa454f3..7614a79cd 100644 --- a/packages/keystatic/src/form/fields/image/ui.tsx +++ b/packages/keystatic/src/form/fields/image/ui.tsx @@ -1,12 +1,19 @@ import { ButtonGroup, ActionButton } from '@keystar/ui/button'; import { FieldDescription, FieldLabel, FieldMessage } from '@keystar/ui/field'; import { Flex, Box } from '@keystar/ui/layout'; +import { ProgressCircle } from '@keystar/ui/progress'; import { tokenSchema } from '@keystar/ui/style'; import { TextField } from '@keystar/ui/text-field'; +import { Text } from '@keystar/ui/typography'; import { useIsInDocumentEditor } from '../document/DocumentEditor'; import { useState, useEffect, useReducer, useId } from 'react'; import { FormFieldInputProps } from '../../api'; +import { + compressImage, + formatBytes, + type ImageCompressionConfig, +} from './compress'; export function getUploadedFileObject( accept: string @@ -61,6 +68,11 @@ export function useObjectURL( return url; } +type CompressionInfo = { + originalSize: number; + compressedSize: number; +}; + // TODO: button labels ("Choose file", "Remove") need i18n support export function ImageFieldInput( props: FormFieldInputProps<{ @@ -72,10 +84,14 @@ export function ImageFieldInput( description: string | undefined; validation: { isRequired?: boolean } | undefined; transformFilename: ((originalFile: string) => string) | undefined; + compression: ImageCompressionConfig | undefined; } ) { const { value } = props; const [blurred, onBlur] = useReducer(() => true, false); + const [isCompressing, setIsCompressing] = useState(false); + const [compressionInfo, setCompressionInfo] = + useState(null); const isInEditor = useIsInDocumentEditor(); const objectUrl = useObjectURL( value === null ? null : value.data, @@ -105,29 +121,68 @@ export function ImageFieldInput( )} { - const image = await getUploadedImage(); - if (image) { - const extension = image.filename.match(/\.([^.]+$)/)?.[1]; - if (extension) { + const file = await getUploadedFileObject('image/*'); + if (!file) return; + + const originalExtension = file.name.match(/\.([^.]+$)/)?.[1]; + if (!originalExtension) return; + + // If compression is configured, compress the image + if (props.compression) { + setIsCompressing(true); + setCompressionInfo(null); + try { + const result = await compressImage(file, props.compression); + setCompressionInfo({ + originalSize: result.originalSize, + compressedSize: result.compressedSize, + }); + // Update filename extension if format changed + let filename = file.name; + if (result.extension !== originalExtension.toLowerCase()) { + filename = file.name.replace( + /\.[^.]+$/, + `.${result.extension}` + ); + } + if (props.transformFilename) { + filename = props.transformFilename(filename); + } props.onChange({ - data: image.content, - extension, - filename: props.transformFilename - ? props.transformFilename(image.filename) - : image.filename, + data: result.data, + extension: result.extension, + filename, }); + } finally { + setIsCompressing(false); } + } else { + // No compression, use original file + const content = new Uint8Array(await file.arrayBuffer()); + props.onChange({ + data: content, + extension: originalExtension, + filename: props.transformFilename + ? props.transformFilename(file.name) + : file.name, + }); } }} > - Choose file + {isCompressing ? ( + + ) : ( + 'Choose file' + )} {value !== null && ( { props.onChange(null); + setCompressionInfo(null); onBlur(); }} > @@ -154,6 +209,19 @@ export function ImageFieldInput( /> )} + {compressionInfo && + compressionInfo.originalSize !== compressionInfo.compressedSize && ( + + {formatBytes(compressionInfo.originalSize)} →{' '} + {formatBytes(compressionInfo.compressedSize)} ( + {Math.round( + (1 - + compressionInfo.compressedSize / compressionInfo.originalSize) * + 100 + )} + % smaller) + + )} {isInEditor && value !== null && ( =8'} hasBin: true + uzip@0.20201231.0: + resolution: {integrity: sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==} + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -22640,6 +22649,10 @@ snapshots: browser-assert@1.2.1: {} + browser-image-compression@2.0.2: + dependencies: + uzip: 0.20201231.0 + browserify-aes@1.2.0: dependencies: buffer-xor: 1.0.3 @@ -30999,6 +31012,8 @@ snapshots: kleur: 4.1.5 sade: 1.8.1 + uzip@0.20201231.0: {} + v8-compile-cache-lib@3.0.1: {} v8-compile-cache@2.4.0: {}