Eidos

Excalidraw

By: Mayne

Install Latest (v0.0.1)

Excalidraw for Eidos

import { Excalidraw } from "@excalidraw/excalidraw"
import "@excalidraw/excalidraw/dist/prod/index.css";
import { useState, useCallback, useEffect, useRef } from "react";
import { useDebounceCallback } from "usehooks-ts";

window.EXCALIDRAW_ASSET_PATH =
  "https://esm.sh/@excalidraw/excalidraw/dist/prod/";

export async function getServerSideProps(context) {
  const url = new URL(context.request.url)
  const nodeId = url.pathname.split('/')[1]
  const nodeText = await context.currentSpace.extNode.getText(nodeId)
  return { props: { savedText: nodeText } }
}

export default function WhiteboardApp({ savedText }) {
  const currentNodeId = window.location.pathname.split('/')[1]
  const [initialData, setInitialData] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  const lastSavedDataRef = useRef({ elements: [], files: {} });

  const getAppStateKey = (nodeId) => `excalidraw-appstate-${nodeId}`;

  const loadInitialData = useCallback(async (nodeId) => {
    if (!nodeId) return null;

    try {
      console.log(`Loading initial data for node: ${nodeId}`);
      let elements = [];
      let files = {};

      if (savedText) {
        const parsedData = JSON.parse(savedText);
        elements = parsedData?.elements || [];
        files = parsedData?.files || {};
      }

      lastSavedDataRef.current = { elements, files };

      const appStateKey = getAppStateKey(nodeId);
      let appState = {};
      try {
        const savedAppState = localStorage.getItem(appStateKey);
        if (savedAppState) {
          appState = JSON.parse(savedAppState);
        }
      } catch (error) {
        console.warn('Failed to load appState from localStorage:', error);
      }

      return {
        elements,
        appState: {
          ...appState,
          collaborators: [],
        },
        files
      };
    } catch (error) {
      console.error('❌ Failed to load initial data:', error);
      return {
        elements: [],
        appState: {},
        files: {}
      };
    }
  }, []);

  const hasDataChanged = useCallback((elements, files) => {
    const lastSaved = lastSavedDataRef.current;

    const elementsChanged = JSON.stringify(elements) !== JSON.stringify(lastSaved.elements);
    const filesChanged = JSON.stringify(files) !== JSON.stringify(lastSaved.files);

    return elementsChanged || filesChanged;
  }, []);

  const debouncedSaveToDatabase = useDebounceCallback(async (elements, files) => {
    if (!currentNodeId) {
      return;
    }

    if (!hasDataChanged(elements, files)) {
      console.log('📋 No changes detected, skipping database save');
      return;
    }

    try {
      const dataToSave = JSON.stringify({ elements, files });
      console.log(`Saving drawing data to eidos (${dataToSave.length} characters)...`);
      await eidos.currentSpace.extNode.setText(currentNodeId, dataToSave);
      lastSavedDataRef.current = { elements, files };
    } catch (error) {
      console.error('❌ Failed to save data:', error);
    }
  }, 1000);

  const saveAppStateToLocalStorage = useCallback((appState) => {
    if (!currentNodeId) {
      return;
    }

    try {
      const appStateKey = getAppStateKey(currentNodeId);
      const { collaborators, ...stateToSave } = appState;
      localStorage.setItem(appStateKey, JSON.stringify(stateToSave));
    } catch (error) {
      console.error('❌ Failed to save appState to localStorage:', error);
    }
  }, [currentNodeId]);

  useEffect(() => {
    const initializeData = async () => {
      if (currentNodeId) {
        const data = await loadInitialData(currentNodeId);
        setInitialData(data);
        setIsLoading(false);
      }
    };
    initializeData();
  }, []);

  const handleChange = useCallback((elements, appState, files) => {
    saveAppStateToLocalStorage(appState);
    debouncedSaveToDatabase(elements, files);
  }, [saveAppStateToLocalStorage, debouncedSaveToDatabase]);

  if (!currentNodeId) {
    return <div>
      This Block only works as Ext Node handler
    </div>
  }

  if (isLoading || !initialData) {
    return (
      <div style={{
        width: '100%',
        height: '100vh',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
      }}>
        <div>Loading whiteboard...</div>
      </div>
    );
  }

  return (
    <div
      className="custom-styles"
      style={{
        width: '100%',
        height: '100vh',
        display: 'flex',
        flexDirection: 'column',
        fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
      }}>
      <Excalidraw
        initialData={initialData}
        onChange={handleChange}
      />
    </div>
  );
}

Information

Author
Mayne
Type
m_block
Latest Version
0.0.1
Last Updated
06/06/2025
Published
06/06/2025

Version History

  • v0.0.1 06/06/2025