Eidos

Installation Notice

The installation may currently fail. We recommend copying the code below and creating the extension manually in Eidos.

ImageCompressor

By: Mayne

Install Latest (v0.0.2)

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>
  )
}

Information

Author
Mayne
Type
block/fileHandler
Latest Version
0.0.2
Last Updated
11/19/2025
Published
11/14/2025

Version History

  • v0.0.2 11/19/2025
  • v0.0.1 11/14/2025