The installation may currently fail. We recommend copying the code below and creating the extension manually in Eidos.
By: Mayne
Handle specific file types
import React, { useEffect, useState, useRef } from "react"
import { encode } from "@jsquash/webp"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Slider } from "@/components/ui/slider"
import { Download, GripVertical, ImageIcon } from "lucide-react"
// Image Compressor Meta Configuration
export const meta = {
type: "fileHandler",
componentName: "ImageCompressor",
fileHandler: {
title: "Image Compressor",
description: "Compress and optimize images with WebP encoding",
extensions: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff"],
icon: "🖼️",
},
}
function useFilePathFromHash() {
const [filePath, setFilePath] = useState("")
useEffect(() => {
const updatePath = () => {
const hash = window.location.hash
const path = hash.startsWith("#") ? hash.substring(1) : hash
setFilePath(decodeURIComponent(path))
}
updatePath()
window.addEventListener("hashchange", updatePath)
return () => window.removeEventListener("hashchange", updatePath)
}, [])
return filePath
}
export default function ImageCompressor() {
const [originalImage, setOriginalImage] = useState(null)
const [compressedImage, setCompressedImage] = useState(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const [quality, setQuality] = useState(85)
const [targetWidth, setTargetWidth] = useState("")
const [targetHeight, setTargetHeight] = useState("")
const [preserveAspectRatio, setPreserveAspectRatio] = useState(true)
const [originalSize, setOriginalSize] = useState(0)
const [compressedSize, setCompressedSize] = useState(0)
const [compressionRatio, setCompressionRatio] = useState(0)
const [viewMode, setViewMode] = useState("compare") // Only "compare" mode
const [dividerPosition, setDividerPosition] = useState(50)
const [isDragging, setIsDragging] = useState(false)
const filePath = useFilePathFromHash()
const canvasRef = useRef(null)
const compareContainerRef = useRef(null)
useEffect(() => {
const loadImage = async () => {
if (!filePath) return
// Check if file is an image by extension
const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp', '.tiff', '.svg']
const fileExt = filePath.toLowerCase().substring(filePath.lastIndexOf('.'))
if (!imageExtensions.includes(fileExt)) {
setError("This file is not an image format. Please select an image file.")
setOriginalImage(null)
setCompressedImage(null)
setOriginalSize(0)
setCompressedSize(0)
setCompressionRatio(0)
return
}
try {
// Reset all states when switching files
setOriginalImage(null)
setCompressedImage(null)
setOriginalSize(0)
setCompressedSize(0)
setCompressionRatio(0)
setError("")
setIsLoading(true)
const fileData = await eidos.currentSpace.fs.readFile(filePath)
const url = '/' + filePath
const img = new Image()
img.onload = () => {
setOriginalImage(img)
setOriginalSize(new Blob([fileData]).size)
setTargetWidth(img.width)
setTargetHeight(img.height)
setIsLoading(false)
}
img.onerror = () => {
setError("Failed to load image. File may be corrupted or unsupported.")
setIsLoading(false)
}
img.src = url
} catch (err) {
console.error("Error loading image:", err)
setError(err.message || "Failed to load image")
setIsLoading(false)
}
}
loadImage()
}, [filePath])
const handleCompress = async () => {
if (!originalImage) return
try {
setIsLoading(true)
// Create canvas and draw original image
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
let width = originalImage.width
let height = originalImage.height
// Apply resize if dimensions changed
if (targetWidth && targetHeight) {
width = parseInt(targetWidth)
height = parseInt(targetHeight)
}
canvas.width = width
canvas.height = height
ctx.drawImage(originalImage, 0, 0, width, height)
// Get image data
const imageData = ctx.getImageData(0, 0, width, height)
// Encode to WebP using @jsquash/webp
const webpBuffer = await encode(imageData, {
quality: quality,
})
// Create compressed image
const compressedBlob = new Blob([webpBuffer], { type: 'image/webp' })
const compressedUrl = URL.createObjectURL(compressedBlob)
const compressedImg = new Image()
compressedImg.onload = () => {
setCompressedImage(compressedImg)
setCompressedSize(webpBuffer.byteLength)
const ratio = originalSize > 0 ? ((originalSize - webpBuffer.byteLength) / originalSize * 100) : 0
setCompressionRatio(parseFloat(ratio.toFixed(1)))
setIsLoading(false)
}
compressedImg.src = compressedUrl
} catch (err) {
console.error("Error compressing image:", err)
setError(err.message || "Failed to compress image")
setIsLoading(false)
}
}
const handleDownload = () => {
if (!compressedImage) return
const link = document.createElement('a')
link.download = `compressed_${filePath.split('/').pop().replace(/\.[^/.]+$/, "")}.webp`
link.href = compressedImage.src
link.click()
}
const handleSave = async () => {
if (!compressedImage) return
try {
setIsLoading(true)
// Get the compressed image data
const response = await fetch(compressedImage.src)
console.log('compressedImage.src', compressedImage.src)
const blob = await response.blob()
console.log('blob', blob)
const arrayBuffer = await blob.arrayBuffer()
// Generate new filename with _compressed suffix
const originalName = filePath.split('/').pop()
const nameWithoutExt = originalName.replace(/\.[^/.]+$/, "")
const newFileName = `${nameWithoutExt}_compressed.webp`
// Get the directory path
const pathParts = filePath.split('/')
pathParts.pop() // Remove filename
const directoryPath = pathParts.join('/')
const fullPath = directoryPath ? `${directoryPath}/${newFileName}` : newFileName
// Convert ArrayBuffer to Uint8Array and save the file using fs.writeFile
console.log('new Uint8Array(arrayBuffer)', new Uint8Array(arrayBuffer))
await eidos.currentSpace.fs.writeFile(fullPath, new Uint8Array(arrayBuffer))
setError("")
alert(`Saved as ${newFileName}`)
} catch (err) {
console.error("Error saving image:", err)
setError(err.message || "Failed to save image")
} finally {
setIsLoading(false)
}
}
const handleMouseMove = (e) => {
if (!isDragging || !compareContainerRef.current) return
const rect = compareContainerRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
const percentage = (x / rect.width) * 100
setDividerPosition(Math.min(100, Math.max(0, percentage)))
}
const handleMouseUp = () => {
setIsDragging(false)
}
const handleMouseDown = (e) => {
setIsDragging(true)
handleMouseMove(e)
}
useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}
}, [isDragging])
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
if (error) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<div className="text-destructive mb-2">Error</div>
<div className="text-sm text-muted-foreground">{error}</div>
</div>
</div>
)
}
return (
<div className="flex h-screen flex-col">
{/* Header - Minimalist style */}
<div className="border-b px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm font-medium truncate">
{filePath.split("/").pop()}
</div>
{originalSize > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{formatFileSize(originalSize)}
</span>
{compressedSize > 0 && (
<>
<span className="text-xs text-green-600">
→ {formatFileSize(compressedSize)}
</span>
<span className="text-xs font-medium text-green-600">
(↓ {compressionRatio}%)
</span>
</>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleCompress}
disabled={isLoading}
size="sm"
>
{isLoading ? "Processing..." : "Compress"}
</Button>
{compressedImage && (
<>
<Button
onClick={handleSave}
disabled={isLoading}
variant="outline"
size="sm"
>
Save
</Button>
<Button
onClick={handleDownload}
variant="outline"
size="sm"
>
<Download className="w-4 h-4" />
</Button>
</>
)}
</div>
</div>
{/* Settings Panel - Minimalist */}
<div className="border-b px-4 py-3 bg-muted/30">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Label className="text-xs">Quality</Label>
<Slider
value={[quality]}
onValueChange={(value) => setQuality(value[0])}
max={100}
min={1}
step={1}
className="w-32"
/>
<span className="text-xs text-muted-foreground w-8">{quality}%</span>
</div>
<div className="flex items-center gap-2">
<Label className="text-xs">Size</Label>
<Input
type="number"
value={targetWidth}
onChange={(e) => setTargetWidth(e.target.value)}
className="w-20 h-8 text-xs"
placeholder="Width"
/>
<span className="text-xs text-muted-foreground">×</span>
<Input
type="number"
value={targetHeight}
onChange={(e) => setTargetHeight(e.target.value)}
className="w-20 h-8 text-xs"
placeholder="Height"
/>
</div>
</div>
</div>
{/* Image Preview - Always show original image */}
<div className="flex-1 relative overflow-hidden bg-muted/10">
{originalImage ? (
<div
ref={compareContainerRef}
className="w-full h-full relative cursor-ew-resize select-none"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
>
{/* Original Image (left side) */}
<div className="absolute inset-0 overflow-hidden">
<img
src={originalImage.src}
alt="Original"
className="w-full h-full object-contain"
/>
</div>
{/* Compressed Image (right side) - only show if exists */}
{compressedImage && (
<>
<div
className="absolute inset-0 overflow-hidden"
style={{ clipPath: `inset(0 0 0 ${dividerPosition}%)` }}
>
<img
src={compressedImage.src}
alt="Compressed"
className="w-full h-full object-contain"
/>
</div>
{/* Divider Line */}
<div
className="absolute top-0 bottom-0 w-px bg-border cursor-ew-resize"
style={{ left: `${dividerPosition}%`, transform: 'translateX(-50%)' }}
>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-8 h-8 bg-background border rounded-full flex items-center justify-center shadow-sm">
<GripVertical className="w-4 h-4 text-muted-foreground" />
</div>
</div>
{/* Labels */}
<div className="absolute top-4 left-4 bg-background/80 px-2 py-1 rounded text-xs">
Original
</div>
<div className="absolute top-4 right-4 bg-background/80 px-2 py-1 rounded text-xs">
Compressed
</div>
</>
)}
{/* Show original label when no compression */}
{!compressedImage && (
<div className="absolute top-4 left-4 bg-background/80 px-2 py-1 rounded text-xs">
Original Image
</div>
)}
</div>
) : (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<ImageIcon className="w-12 h-12 text-muted-foreground mx-auto mb-2" />
<div className="text-sm text-muted-foreground">Loading image...</div>
</div>
</div>
)}
</div>
</div>
)
}