Files
excalicloud/frontend/src/App.tsx
T
2026-10-07 11:51:38 +02:00

344 lines
12 KiB
TypeScript

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<AppState, "viewBackgroundColor">,
files: BinaryFiles,
) => `${hashElementsVersion(elements)}:${Object.keys(files).length}:${appState.viewBackgroundColor}`;
const SAVE_LABEL: Record<SaveState, string> = {
saved: "Enregistré",
dirty: "Modifié",
saving: "Enregistrement…",
error: "Erreur d'enregistrement",
};
export default function App() {
const [theme, setTheme] = useState<Theme>(initialTheme);
const [library, setLibrary] = useState<LibraryItems | null>(null);
const [scene, setScene] = useState<Scene | null>(null);
const [saveState, setSaveState] = useState<SaveState>("saved");
const [drawerOpen, setDrawerOpen] = useState(() => !isMobile());
const [refreshKey, setRefreshKey] = useState(0);
const [loadError, setLoadError] = useState<string | null>(null);
const excalidraw = useRef<ExcalidrawImperativeAPI | null>(null);
const pathRef = useRef<string | null>(null); // schéma ouvert (survit aux renommages)
const etagRef = useRef("");
const savedVersion = useRef("");
const timer = useRef<number>(undefined);
const inflight = useRef<Promise<void>>(Promise.resolve());
const libTimer = useRef<number>(undefined);
const libJson = useRef(""); // dernier état connu côté serveur
const sceneSeq = useRef(0);
const openRef = useRef<(path: string, opts?: { skipFlush?: boolean }) => Promise<void>>(async () => {});
// --- Sauvegarde -----------------------------------------------------------
const doSave = useCallback(async (force = false): Promise<void> => {
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 (
<div className={`app ${drawerOpen ? "drawer-open" : ""}`}>
<header className="topbar">
<button
className="icon-btn"
aria-label="Afficher/masquer l'explorateur"
onClick={() => setDrawerOpen((o) => !o)}
>
☰
</button>
<span className="title" title={scene?.path}>
{scene ? stripExt(basename(scene.path)) : "Excalidraw"}
</span>
{scene && (
<>
<span className={`status ${saveState}`}>{SAVE_LABEL[saveState]}</span>
<button onClick={() => save()} disabled={saveState === "saved" || saveState === "saving"}>
Enregistrer
</button>
<button className="icon-btn" aria-label="Fermer le schéma" onClick={() => save().then(closeFile)}>
✕
</button>
</>
)}
</header>
<aside className="drawer">
<Explorer
current={scene?.path ?? null}
refreshKey={refreshKey}
onOpen={(p) => openFile(p)}
onMoved={onMoved}
onDeleted={onDeleted}
/>
</aside>
<div className="backdrop" onClick={() => setDrawerOpen(false)} />
<main className="canvas">
{loadError && <div className="error">{loadError}</div>}
{scene && library ? (
<Excalidraw
// Remontage complet à chaque ouverture : historique et état propres.
key={scene.id}
initialData={{ ...scene.initialData, libraryItems: library }}
excalidrawAPI={onApi}
onChange={onChange}
onLibraryChange={onLibraryChange}
theme={theme}
langCode="fr-FR"
name={stripExt(basename(scene.path))}
UIOptions={{
canvasActions: { loadScene: false, saveToActiveFile: false, toggleTheme: true },
}}
/>
) : (
<div className="placeholder muted">
<p>Sélectionnez un schéma dans l'explorateur ou créez-en un nouveau.</p>
</div>
)}
</main>
</div>
);
}