diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..b9f9ec0 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,343 @@ +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.

+
+ )} +
+
+ ); +}