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
+127
-12
No files matched your search
+82
-12
@@ -2,7 +2,7 @@ import { type CSSProperties, useCallback, useEffect, useRef, useState } from "re
|
|||||||
import {
|
import {
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
hashElementsVersion,
|
hashElementsVersion,
|
||||||
restoreLibraryItems,
|
mergeLibraryItems,
|
||||||
serializeAsJSON,
|
serializeAsJSON,
|
||||||
serializeLibraryAsJSON,
|
serializeLibraryAsJSON,
|
||||||
} from "@excalidraw/excalidraw";
|
} from "@excalidraw/excalidraw";
|
||||||
@@ -23,6 +23,7 @@ import { Splitter } from "./Splitter";
|
|||||||
import { SettingsDialog, DEFAULT_TITLE } from "./SettingsDialog";
|
import { SettingsDialog, DEFAULT_TITLE } from "./SettingsDialog";
|
||||||
import { pendingVersion, useUpdateStatus } from "./update";
|
import { pendingVersion, useUpdateStatus } from "./update";
|
||||||
import { storeThumb } from "./thumbs";
|
import { storeThumb } from "./thumbs";
|
||||||
|
import { fetchLibraryItems, libraryUrlFromHash, parseServerLibrary } from "./library";
|
||||||
import { fitScene } from "./fit";
|
import { fitScene } from "./fit";
|
||||||
|
|
||||||
type Theme = "light" | "dark";
|
type Theme = "light" | "dark";
|
||||||
@@ -38,6 +39,10 @@ const AUTOSAVE_MS = 1500;
|
|||||||
const isMobile = () => window.matchMedia("(max-width: 768px)").matches;
|
const isMobile = () => window.matchMedia("(max-width: 768px)").matches;
|
||||||
const pathFromHash = () => decodeURIComponent(location.hash.replace(/^#\/?/, "")) || null;
|
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) => {
|
const load = (key: string) => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(key);
|
return localStorage.getItem(key);
|
||||||
@@ -105,6 +110,7 @@ export default function App() {
|
|||||||
const inflight = useRef<Promise<void>>(Promise.resolve());
|
const inflight = useRef<Promise<void>>(Promise.resolve());
|
||||||
const libTimer = useRef<number>(undefined);
|
const libTimer = useRef<number>(undefined);
|
||||||
const libJson = useRef(""); // dernier état connu côté serveur
|
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 sceneSeq = useRef(0);
|
||||||
const fitPending = useRef(false); // cadrage à faire au premier rendu du schéma ouvert
|
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
|
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 } = {}) => {
|
async (path: string, opts: { skipFlush?: boolean } = {}) => {
|
||||||
if (!opts.skipFlush) await save();
|
if (!opts.skipFlush) await save();
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
|
const showLibrary = libMenuPending.current;
|
||||||
|
libMenuPending.current = false;
|
||||||
try {
|
try {
|
||||||
const { text, etag } = await api.read(path);
|
const { text, etag } = await api.read(path);
|
||||||
const data = JSON.parse(text);
|
const data = JSON.parse(text);
|
||||||
@@ -207,12 +215,17 @@ export default function App() {
|
|||||||
path,
|
path,
|
||||||
initialData: {
|
initialData: {
|
||||||
elements: data.elements ?? [],
|
elements: data.elements ?? [],
|
||||||
appState: { ...(data.appState ?? {}), theme },
|
appState: {
|
||||||
|
...(data.appState ?? {}),
|
||||||
|
theme,
|
||||||
|
...(showLibrary ? { openSidebar: { name: "default", tab: "library" } } : {}),
|
||||||
|
},
|
||||||
files: data.files ?? {},
|
files: data.files ?? {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setSaveState("saved");
|
setSaveState("saved");
|
||||||
if (location.hash !== `#/${path}`) history.replaceState(null, "", `#/${encodeURI(path)}`);
|
if (location.hash !== `#/${path}`) history.replaceState(null, "", `#/${encodeURI(path)}`);
|
||||||
|
store("lastScene", path);
|
||||||
if (isMobile()) setDrawerOpen(false);
|
if (isMobile()) setDrawerOpen(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoadError(`Impossible d'ouvrir « ${path} » : ${e instanceof Error ? e.message : e}`);
|
setLoadError(`Impossible d'ouvrir « ${path} » : ${e instanceof Error ? e.message : e}`);
|
||||||
@@ -240,32 +253,89 @@ export default function App() {
|
|||||||
|
|
||||||
// --- Effets globaux --------------------------------------------------------
|
// --- Effets globaux --------------------------------------------------------
|
||||||
|
|
||||||
// Bibliothèque partagée, chargée une fois.
|
// Bibliothèque partagée entre tous les schémas, stockée côté serveur.
|
||||||
useEffect(() => {
|
const readLibrary = useCallback(async () => {
|
||||||
api
|
const items = parseServerLibrary(await api.readLibrary());
|
||||||
.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);
|
libJson.current = serializeLibraryAsJSON(items);
|
||||||
setLibrary(items);
|
return items;
|
||||||
})
|
|
||||||
.catch(() => setLibrary([]));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
readLibrary()
|
||||||
|
.then((items) => setLibrary((cur) => cur ?? items))
|
||||||
|
.catch(() => setLibrary((cur) => cur ?? []));
|
||||||
|
}, [readLibrary]);
|
||||||
|
|
||||||
const onLibraryChange = useCallback((items: LibraryItems) => {
|
const onLibraryChange = useCallback((items: LibraryItems) => {
|
||||||
clearTimeout(libTimer.current);
|
clearTimeout(libTimer.current);
|
||||||
libTimer.current = window.setTimeout(() => {
|
libTimer.current = window.setTimeout(() => {
|
||||||
const json = serializeLibraryAsJSON(items);
|
const json = serializeLibraryAsJSON(items);
|
||||||
if (json === libJson.current) return; // appel initial au montage
|
if (json === libJson.current) return; // appel initial au montage
|
||||||
libJson.current = json;
|
libJson.current = json;
|
||||||
|
setLibrary(items); // reprise à l'ouverture du schéma suivant
|
||||||
api.writeLibrary(json).catch(console.error);
|
api.writeLibrary(json).catch(console.error);
|
||||||
}, 500);
|
}, 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.
|
// Lien direct #/chemin/schema.excalidraw et navigation arrière/avant.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onHash = () => {
|
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();
|
const p = pathFromHash();
|
||||||
if (p && p !== pathRef.current) openFile(p);
|
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