The installation may currently fail. We recommend copying the code below and creating the extension manually in Eidos.
By: Mayne
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>
)
}