Bibliothèques : prise en charge des liens « Ajouter à Excalidraw »
libraries.excalidraw.com renvoie vers l'application avec #addLibrary=<url>&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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MvjHH36mmWGqRw5AiHpLQd
This commit is contained in:
1 parent
6423bb6da3
commit
2b6b6fc72c
2 files changed
+128
-13
No files matched your search
+83
-13
@@ -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<void>>(Promise.resolve());
|
||||
const libTimer = useRef<number>(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<string | null>(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=<url>&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);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Bibliothèque Excalidraw : stockage côté serveur et liens « Ajouter à Excalidraw »
|
||||
// de libraries.excalidraw.com (#addLibrary=<url>&token=<id>).
|
||||
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<LibraryItems> {
|
||||
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");
|
||||
};
|
||||
Reference in new issue
Block a user