Eidos

Installation Notice

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

Excalidraw Editor

By: Mayne

Install Latest (v0.0.1)

View and edit Excalidraw files

import React, { useEffect, useState, useRef } from "react"
import { Excalidraw, serializeAsJSON, exportToBlob } from "@excalidraw/excalidraw"
import "@excalidraw/excalidraw/dist/prod/index.css";

(window as any).EXCALIDRAW_ASSET_PATH = "https://esm.sh/@excalidraw/excalidraw/dist/prod/";

// File Handler Meta Configuration
export const meta = {
  type: "fileHandler",
  componentName: "FileHandler",
  fileHandler: {
    title: "Excalidraw Editor",
    description: "View and edit Excalidraw files",
    extensions: [".excalidraw"], // Change this to your target file extensions
    icon: "🎨",
  },
}

function useFilePathFromHash() {
  const [filePath, setFilePath] = useState("")

  useEffect(() => {
    const updatePath = () => {
      const hash = window.location.hash
      const path = hash.startsWith("#") ? hash.substring(1) : hash
      // Decode URL-encoded characters (e.g., %20 -> space)
      setFilePath(decodeURIComponent(path))
    }

    updatePath()
    window.addEventListener("hashchange", updatePath)

    return () => window.removeEventListener("hashchange", updatePath)
  }, [])

  return filePath
}

export default function FileHandler() {
  const [content, setContent] = useState("")
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState("")
  const filePath = useFilePathFromHash()

  // Excalidraw state
  const sceneDataRef = useRef(null)
  const isExcalidraw = filePath.endsWith(".excalidraw")

  useEffect(() => {
    const loadFile = async () => {
      try {
        setIsLoading(true)
        const text = await eidos.currentSpace.fs.readFile(filePath, "utf8")
        setContent(text)

        // Initialize sceneDataRef for Excalidraw
        if (filePath.endsWith(".excalidraw")) {
          try {
            sceneDataRef.current = JSON.parse(text)
          } catch (e) {
            console.warn("Failed to parse excalidraw file, starting empty")
            sceneDataRef.current = { elements: [], appState: {} }
          }
        }

        setError("")
      } catch (err: any) {
        console.error("Error reading file:", err)
        setError(err.message || "Failed to read file")
      } finally {
        setIsLoading(false)
      }
    }

    if (filePath) {
      loadFile()
    }
  }, [filePath])

  const handleSave = async () => {
    try {
      let dataToSave = content

      if (isExcalidraw && sceneDataRef.current) {
        const { elements, appState, files } = sceneDataRef.current
        // Use serializeAsJSON to ensure valid Excalidraw file format (includes type, version, etc.)
        dataToSave = serializeAsJSON(elements, appState, files || {}, "local")
      }

      await eidos.currentSpace.fs.writeFile(filePath, dataToSave, "utf8")
      eidos.currentSpace.notify({
        title: "Success",
        description: "File saved",
      })
    } catch (err: any) {
      console.error("Error saving file:", err)
      setError(err.message || "Failed to save file")
      eidos.currentSpace.notify({
        title: "Error",
        description: err.message || "Failed to save file",
      })
    }
  }

  const handleExportImage = async () => {
    if (!isExcalidraw || !sceneDataRef.current) return

    try {
      const { elements, appState, files } = sceneDataRef.current
      const blob = await exportToBlob({
        elements,
        appState,
        files,
        mimeType: "image/png",
      })

      const imagePath = filePath.replace(/\.excalidraw$/, ".png")
      const arrayBuffer = await blob.arrayBuffer()
      const uint8Array = new Uint8Array(arrayBuffer)

      await eidos.currentSpace.fs.writeFile(imagePath, uint8Array)

      eidos.currentSpace.notify({
        title: "Success",
        description: `Image saved: ${imagePath.split("/").pop()}`,
      })
    } catch (err: any) {
      console.error("Error exporting image:", err)
      eidos.currentSpace.notify({
        title: "Error",
        description: err.message || "Failed to export image",
      })
    }
  }

  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 */}
      <div className="border-b px-4 py-1 flex items-center justify-between bg-background">
        <div className="text-sm font-medium truncate">
          {filePath.split("/").pop()}
        </div>
        <div className="flex gap-2">
          {isExcalidraw && (
            <button
              onClick={handleExportImage}
              className="px-3 py-1 text-sm bg-secondary text-secondary-foreground rounded hover:bg-secondary/80"
            >
              Export PNG
            </button>
          )}
          <button
            onClick={handleSave}
            className="px-3 py-1 text-sm bg-primary text-primary-foreground rounded hover:bg-primary/90"
          >
            Save
          </button>
        </div>
      </div>

      {/* Content Area */}
      <div className="flex-1 overflow-hidden relative">
        {isExcalidraw ? (
          !isLoading && (
            <div className="w-full h-full">
              <Excalidraw
                key={filePath}
                initialData={(() => {
                  try {
                    return content ? JSON.parse(content) : null
                  } catch (e) {
                    return null
                  }
                })()}
                onChange={(elements, appState, files) => {
                  sceneDataRef.current = { elements, appState, files }
                }}
              />
            </div>
          )
        ) : (
          <textarea
            value={content}
            onChange={(e) => setContent(e.target.value)}
            className="w-full h-full p-4 resize-none border-none focus:outline-none font-mono text-sm bg-background text-foreground"
            placeholder="File content..."
          />
        )}
      </div>
    </div>
  )
}

Information

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

Version History

  • v0.0.1 11/19/2025