From 2b6b6fc72c086eaf3a460e514d7a5333b9147480 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 8 Oct 2026 08:19:47 +0200 Subject: [PATCH] =?UTF-8?q?Biblioth=C3=A8ques=20:=20prise=20en=20charge=20?= =?UTF-8?q?des=20liens=20=C2=AB=20Ajouter=20=C3=A0=20Excalidraw=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libraries.excalidraw.com renvoie vers l'application avec #addLibrary=&token=… Le routeur par hash prenait ce fragment pour un chemin de schéma (« extension .excalidraw attendue »). Le lien est désormais intercepté avant le routeur : téléchargement du .excalidrawlib (mêmes sources autorisées qu'Excalidraw), confirmation, fusion dans la bibliothèque partagée stockée côté serveur. - Schéma ouvert : fusion dans l'éditeur et ouverture du panneau Bibliothèque. - Nouvel onglet : enregistrement côté serveur puis réouverture du dernier schéma. - window.name propre à l'onglet pour que le site des bibliothèques revienne dans l'onglet d'origine. - La bibliothèque suit les modifications d'un schéma à l'autre et est relue au retour sur l'onglet (évite d'écraser un ajout fait ailleurs). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MvjHH36mmWGqRw5AiHpLQd --- frontend/src/App.tsx | 96 +++++++++++++++++++++++++++++++++++------ frontend/src/library.ts | 45 +++++++++++++++++++ 2 files changed, 128 insertions(+), 13 deletions(-) create mode 100644 frontend/src/library.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bcefc73..8abd549 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,7 @@ import { type CSSProperties, useCallback, useEffect, useRef, useState } from "re import { Excalidraw, hashElementsVersion, - restoreLibraryItems, + mergeLibraryItems, serializeAsJSON, serializeLibraryAsJSON, } from "@excalidraw/excalidraw"; @@ -23,6 +23,7 @@ import { Splitter } from "./Splitter"; import { SettingsDialog, DEFAULT_TITLE } from "./SettingsDialog"; import { pendingVersion, useUpdateStatus } from "./update"; import { storeThumb } from "./thumbs"; +import { fetchLibraryItems, libraryUrlFromHash, parseServerLibrary } from "./library"; import { fitScene } from "./fit"; type Theme = "light" | "dark"; @@ -38,6 +39,10 @@ const AUTOSAVE_MS = 1500; const isMobile = () => window.matchMedia("(max-width: 768px)").matches; const pathFromHash = () => decodeURIComponent(location.hash.replace(/^#\/?/, "")) || null; +// « Ajouter à Excalidraw » sur libraries.excalidraw.com revient vers la fenêtre nommée +// window.name : avec un nom propre à l'onglet, l'ajout arrive ici sans ouvrir d'onglet. +if (!window.name) window.name = `excalicloud-${Math.random().toString(36).slice(2)}`; + const load = (key: string) => { try { return localStorage.getItem(key); @@ -105,6 +110,7 @@ export default function App() { const inflight = useRef>(Promise.resolve()); const libTimer = useRef(undefined); const libJson = useRef(""); // dernier état connu côté serveur + const libMenuPending = useRef(false); // ouvrir la bibliothèque avec le prochain schéma const sceneSeq = useRef(0); const fitPending = useRef(false); // cadrage à faire au premier rendu du schéma ouvert const exTheme = useRef(null); // dernier thème signalé par Excalidraw @@ -195,6 +201,8 @@ export default function App() { async (path: string, opts: { skipFlush?: boolean } = {}) => { if (!opts.skipFlush) await save(); setLoadError(null); + const showLibrary = libMenuPending.current; + libMenuPending.current = false; try { const { text, etag } = await api.read(path); const data = JSON.parse(text); @@ -207,12 +215,17 @@ export default function App() { path, initialData: { elements: data.elements ?? [], - appState: { ...(data.appState ?? {}), theme }, + appState: { + ...(data.appState ?? {}), + theme, + ...(showLibrary ? { openSidebar: { name: "default", tab: "library" } } : {}), + }, files: data.files ?? {}, }, }); setSaveState("saved"); if (location.hash !== `#/${path}`) history.replaceState(null, "", `#/${encodeURI(path)}`); + store("lastScene", path); if (isMobile()) setDrawerOpen(false); } catch (e) { setLoadError(`Impossible d'ouvrir « ${path} » : ${e instanceof Error ? e.message : e}`); @@ -240,32 +253,89 @@ export default function App() { // --- 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([])); + // Bibliothèque partagée entre tous les schémas, stockée côté serveur. + const readLibrary = useCallback(async () => { + const items = parseServerLibrary(await api.readLibrary()); + libJson.current = serializeLibraryAsJSON(items); + return items; }, []); + useEffect(() => { + readLibrary() + .then((items) => setLibrary((cur) => cur ?? items)) + .catch(() => setLibrary((cur) => cur ?? [])); + }, [readLibrary]); + 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; + setLibrary(items); // reprise à l'ouverture du schéma suivant api.writeLibrary(json).catch(console.error); }, 500); }, []); + // Retour sur l'onglet : reprend la bibliothèque modifiée ailleurs (autre onglet, + // autre poste) pour ne pas l'écraser ensuite avec une copie périmée. + useEffect(() => { + const onVisible = () => { + if (document.hidden) return; + const before = libJson.current; + readLibrary() + .then((items) => { + if (libJson.current === before) return; + setLibrary(items); + excalidraw.current?.updateLibrary({ libraryItems: items }); + }) + .catch(() => {}); + }; + document.addEventListener("visibilitychange", onVisible); + return () => document.removeEventListener("visibilitychange", onVisible); + }, [readLibrary]); + + /** Lien « Ajouter à Excalidraw » de libraries.excalidraw.com (#addLibrary=&token=…). */ + const addLibrary = useCallback( + async (url: string) => { + try { + const incoming = await fetchLibraryItems(url); + if (!confirm(`Ajouter ${incoming.length} élément(s) à la bibliothèque ?\n\n${decodeURIComponent(url)}`)) return; + const ex = excalidraw.current; + if (ex) { + // Schéma ouvert : fusion dans l'éditeur, onLibraryChange enregistre côté serveur. + await ex.updateLibrary({ libraryItems: incoming, merge: true, openLibraryMenu: true }); + return; + } + const items = mergeLibraryItems(await readLibrary(), incoming); + const json = serializeLibraryAsJSON(items); + await api.writeLibrary(json); + libJson.current = json; + setLibrary(items); + // Onglet ouvert par le site des bibliothèques : on rouvre le dernier schéma. + const last = load("lastScene"); + if (last) { + libMenuPending.current = true; + await openRef.current(last); + } + } catch (e) { + setLoadError(`Impossible d'ajouter la bibliothèque : ${e instanceof Error ? e.message : e}`); + } + }, + [readLibrary], + ); + // Lien direct #/chemin/schema.excalidraw et navigation arrière/avant. useEffect(() => { const onHash = () => { + const lib = libraryUrlFromHash(); + if (lib) { + // Le fragment addLibrary n'est pas un chemin de schéma : on le retire. + const cur = pathRef.current; + history.replaceState(null, "", cur ? `#/${encodeURI(cur)}` : location.pathname); + addLibrary(lib); + return; + } const p = pathFromHash(); if (p && p !== pathRef.current) openFile(p); }; diff --git a/frontend/src/library.ts b/frontend/src/library.ts new file mode 100644 index 0000000..ee168e6 --- /dev/null +++ b/frontend/src/library.ts @@ -0,0 +1,45 @@ +// Bibliothèque Excalidraw : stockage côté serveur et liens « Ajouter à Excalidraw » +// de libraries.excalidraw.com (#addLibrary=&token=). +import { loadLibraryFromBlob, parseLibraryTokensFromUrl, restoreLibraryItems } from "@excalidraw/excalidraw"; +import type { LibraryItems } from "@excalidraw/excalidraw/types"; + +// Mêmes sources que le hook useHandleLibrary d'Excalidraw (validateLibraryUrl). +const ALLOWED = ["excalidraw.com", "raw.githubusercontent.com/excalidraw/excalidraw-libraries"]; + +/** URL de la bibliothèque à installer si le fragment est un lien addLibrary, sinon null. */ +export const libraryUrlFromHash = (): string | null => parseLibraryTokensFromUrl()?.libraryUrl ?? null; + +const isAllowed = (raw: string) => { + let url: URL; + try { + url = new URL(raw); + } catch { + return false; + } + if (url.protocol !== "https:") return false; + return ALLOWED.some((a) => { + const [host, ...rest] = a.split("/"); + const path = rest.length ? `/${rest.join("/")}/` : "/"; + return (url.hostname === host || url.hostname.endsWith(`.${host}`)) && url.pathname.startsWith(path); + }); +}; + +/** Télécharge un .excalidrawlib depuis une source autorisée. */ +export async function fetchLibraryItems(raw: string): Promise { + const url = decodeURIComponent(raw); + if (!isAllowed(url)) throw new Error(`source non autorisée (${url})`); + let res: Response; + try { + res = await fetch(url); + } catch { + throw new Error("téléchargement impossible (réseau ou CORS)"); + } + if (!res.ok) throw new Error(`téléchargement impossible (HTTP ${res.status})`); + return loadLibraryFromBlob(await res.blob(), "published"); +} + +/** Contenu de GET /api/library (format .excalidrawlib, v1 ou v2). */ +export const parseServerLibrary = (lib: unknown): LibraryItems => { + const raw = (lib ?? {}) as { libraryItems?: unknown; library?: unknown }; + return restoreLibraryItems((raw.libraryItems ?? raw.library ?? []) as LibraryItems, "unpublished"); +};