import { useCallback, useEffect, useRef, useState } from "react"; import { Excalidraw, hashElementsVersion, restoreLibraryItems, serializeAsJSON, serializeLibraryAsJSON, } from "@excalidraw/excalidraw"; import type { AppState, BinaryFiles, ExcalidrawImperativeAPI, ExcalidrawInitialDataState, LibraryItems, } from "@excalidraw/excalidraw/types"; import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types"; import { api, ApiError, basename, stripExt } from "./api"; import { Explorer } from "./Explorer"; type Theme = "light" | "dark"; type SaveState = "saved" | "dirty" | "saving" | "error"; interface Scene { id: number; // change à chaque ouverture -> remontage de l'éditeur path: string; initialData: ExcalidrawInitialDataState; } const AUTOSAVE_MS = 1500; const isMobile = () => window.matchMedia("(max-width: 768px)").matches; const pathFromHash = () => decodeURIComponent(location.hash.replace(/^#\/?/, "")) || null; const initialTheme = (): Theme => { try { const t = localStorage.getItem("theme"); if (t === "light" || t === "dark") return t; } catch { /* stockage indisponible */ } return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; }; /** Empreinte du contenu « persistant » (hors scroll, sélection, zoom...). */ const sceneVersion = ( elements: readonly OrderedExcalidrawElement[], appState: Pick, files: BinaryFiles, ) => `${hashElementsVersion(elements)}:${Object.keys(files).length}:${appState.viewBackgroundColor}`; const SAVE_LABEL: Record = { saved: "Enregistré", dirty: "Modifié", saving: "Enregistrement…", error: "Erreur d'enregistrement", }; export default function App() { const [theme, setTheme] = useState(initialTheme); const [library, setLibrary] = useState(null); const [scene, setScene] = useState(null); const [saveState, setSaveState] = useState("saved"); const [drawerOpen, setDrawerOpen] = useState(() => !isMobile()); const [refreshKey, setRefreshKey] = useState(0); const [loadError, setLoadError] = useState(null); const excalidraw = useRef(null); const pathRef = useRef(null); // schéma ouvert (survit aux renommages) const etagRef = useRef(""); const savedVersion = useRef(""); const timer = useRef(undefined); const inflight = useRef>(Promise.resolve()); const libTimer = useRef(undefined); const libJson = useRef(""); // dernier état connu côté serveur const sceneSeq = useRef(0); const openRef = useRef<(path: string, opts?: { skipFlush?: boolean }) => Promise>(async () => {}); // --- Sauvegarde ----------------------------------------------------------- const doSave = useCallback(async (force = false): Promise => { const ex = excalidraw.current; const path = pathRef.current; if (!ex || !path) return; const elements = ex.getSceneElements(); const appState = ex.getAppState(); const files = ex.getFiles(); const version = sceneVersion(elements, appState, files); if (version === savedVersion.current && !force) return; setSaveState("saving"); const body = serializeAsJSON(elements, appState, files, "local"); try { etagRef.current = await api.write(path, body, force ? "" : etagRef.current); savedVersion.current = version; // D'autres modifications ont pu arriver pendant l'envoi. const now = sceneVersion(ex.getSceneElements(), ex.getAppState(), ex.getFiles()); setSaveState(now === version ? "saved" : "dirty"); setRefreshKey((k) => k + 1); } catch (e) { if (e instanceof ApiError && e.status === 412) { setSaveState("dirty"); const overwrite = confirm( "Ce schéma a été modifié ailleurs depuis son ouverture.\n\n" + "OK : écraser avec votre version\nAnnuler : recharger la version du serveur (vos modifications seront perdues)", ); if (overwrite) return doSave(true); savedVersion.current = version; // évite une re-sauvegarde avant rechargement await openRef.current(path, { skipFlush: true }); return; } setSaveState("error"); console.error(e); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); /** Sérialise les sauvegardes : jamais deux PUT concurrents sur le même fichier. */ const save = useCallback( (force = false) => { clearTimeout(timer.current); inflight.current = inflight.current.then(() => doSave(force)); return inflight.current; }, [doSave], ); const onChange = useCallback( (elements: readonly OrderedExcalidrawElement[], appState: AppState, files: BinaryFiles) => { if (appState.theme !== theme) setTheme(appState.theme as Theme); if (!pathRef.current || appState.isLoading) return; const version = sceneVersion(elements, appState, files); // Premier onChange après chargement : c'est la version de référence. if (savedVersion.current === "") { savedVersion.current = version; return; } if (version === savedVersion.current) return; setSaveState("dirty"); clearTimeout(timer.current); timer.current = window.setTimeout(() => save(), AUTOSAVE_MS); }, [save, theme], ); // --- Ouverture / fermeture ------------------------------------------------ const openFile = useCallback( async (path: string, opts: { skipFlush?: boolean } = {}) => { if (!opts.skipFlush) await save(); setLoadError(null); try { const { text, etag } = await api.read(path); const data = JSON.parse(text); etagRef.current = etag; savedVersion.current = ""; // fixée au premier onChange (voir onChange) pathRef.current = path; setScene({ id: ++sceneSeq.current, path, initialData: { elements: data.elements ?? [], appState: { ...(data.appState ?? {}), theme }, files: data.files ?? {}, scrollToContent: true, }, }); setSaveState("saved"); if (location.hash !== `#/${path}`) history.replaceState(null, "", `#/${encodeURI(path)}`); if (isMobile()) setDrawerOpen(false); } catch (e) { setLoadError(`Impossible d'ouvrir « ${path} » : ${e instanceof Error ? e.message : e}`); } }, [save, theme], ); const closeFile = useCallback(() => { clearTimeout(timer.current); pathRef.current = null; excalidraw.current = null; setScene(null); history.replaceState(null, "", location.pathname); }, []); openRef.current = openFile; const onApi = useCallback((ex: ExcalidrawImperativeAPI) => { excalidraw.current = ex; }, []); // --- Effets globaux -------------------------------------------------------- // Bibliothèque partagée, chargée une fois. useEffect(() => { api .readLibrary() .then((lib) => { const raw = lib as { libraryItems?: unknown; library?: unknown }; const items = restoreLibraryItems((raw.libraryItems ?? raw.library ?? []) as LibraryItems, "unpublished"); libJson.current = serializeLibraryAsJSON(items); setLibrary(items); }) .catch(() => setLibrary([])); }, []); const onLibraryChange = useCallback((items: LibraryItems) => { clearTimeout(libTimer.current); libTimer.current = window.setTimeout(() => { const json = serializeLibraryAsJSON(items); if (json === libJson.current) return; // appel initial au montage libJson.current = json; api.writeLibrary(json).catch(console.error); }, 500); }, []); // Lien direct #/chemin/schema.excalidraw et navigation arrière/avant. useEffect(() => { const onHash = () => { const p = pathFromHash(); if (p && p !== pathRef.current) openFile(p); }; onHash(); window.addEventListener("hashchange", onHash); return () => window.removeEventListener("hashchange", onHash); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Ctrl/Cmd+S : sauvegarde immédiate (intercepte l'export disque d'Excalidraw). useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { e.preventDefault(); e.stopPropagation(); save(); } }; window.addEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true); }, [save]); // Alerte si fermeture de l'onglet avec des modifications non enregistrées. useEffect(() => { const onUnload = (e: BeforeUnloadEvent) => { if (saveState !== "saved") e.preventDefault(); }; window.addEventListener("beforeunload", onUnload); return () => window.removeEventListener("beforeunload", onUnload); }, [saveState]); useEffect(() => { document.documentElement.dataset.theme = theme; try { localStorage.setItem("theme", theme); } catch { /* stockage indisponible */ } }, [theme]); // --- Synchronisation avec l'explorateur ----------------------------------- const onMoved = useCallback((from: string, to: string) => { const cur = pathRef.current; if (!cur) return; if (cur === from || cur.startsWith(from + "/")) { const next = to + cur.slice(from.length); pathRef.current = next; setScene((s) => (s ? { ...s, path: next } : s)); history.replaceState(null, "", `#/${encodeURI(next)}`); } }, []); const onDeleted = useCallback( (path: string) => { const cur = pathRef.current; if (cur && (cur === path || cur.startsWith(path + "/"))) closeFile(); }, [closeFile], ); // --- Rendu ----------------------------------------------------------------- return (
{scene ? stripExt(basename(scene.path)) : "Excalidraw"} {scene && ( <> {SAVE_LABEL[saveState]} )}
setDrawerOpen(false)} />
{loadError &&
{loadError}
} {scene && library ? ( ) : (

Sélectionnez un schéma dans l'explorateur ou créez-en un nouveau.

)}
); }