Files
ObsiGate/frontend/excalidraw-editor.html
T
bruno e4aca3b31e
CI / lint (push) Successful in 47s
CI / security (push) Successful in 32s
CI / test (push) Successful in 1m0s
CI / build (push) Successful in 30s
CI / e2e (push) Successful in 9m22s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
fix(pdf,excalidraw): BUG-001 + BUG-002
BUG-001 (PDF stream 500) : le nom Unicode du fichier etait injecte brut
dans Content-Disposition -> en-tete HTTP invalide -> 500. Nouveau helper
_content_disposition (RFC 5987 : filename ASCII + filename*=UTF-8'').
Applique aux 2 branches /pdf/stream. Test: tests/test_pdf_stream.py.

BUG-002 (Excalidraw 'Loading...' infini) : deux causes.
1) L'import esm.sh avec ?alias=react:... etait propage a chaque dep
   transitive; esm.sh renvoie 408 sur les builds a gamme semver (jotai@>=2.9.2)
   -> import entier echoue. On retire l'alias et on utilise React 19 coherent
   (spec a gamme + target identique a celle d'Excalidraw) -> plus de 408.
2) L'editeur passait la prop legacy 'excalidrawRef', inoperante en 0.18
   (la prop reelle est 'excalidrawAPI') -> l'API n'etait jamais recue,
   le 'Loading' ne se masquait pas, save/export morts.

Verifie : 534 tests backend verts (dont 3 nouveaux PDF) + E2E navigateur
(loading masque + cycle requestSave->save OK).
2026-09-09 13:04:40 -04:00

329 lines
10 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Excalidraw Editor</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #root { width: 100%; height: 100%; overflow: hidden; }
body { background: #ffffff; }
/* Toolbar overlay in top-right corner */
#excalidraw-toolbar {
position: fixed;
top: 8px;
right: 12px;
z-index: 1000;
display: flex;
gap: 6px;
align-items: center;
}
#excalidraw-toolbar button {
padding: 5px 10px;
border: 1px solid #d0d0d0;
border-radius: 6px;
background: #ffffff;
cursor: pointer;
font-size: 12px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
align-items: center;
gap: 4px;
transition: background 0.15s;
}
#excalidraw-toolbar button:hover { background: #f0f0f0; }
#excalidraw-toolbar button.primary {
background: #6965db;
color: #fff;
border-color: #6965db;
}
#excalidraw-toolbar button.primary:hover { background: #5b57c4; }
/* Dirty indicator */
#dirty-badge {
font-size: 11px;
color: #e07b39;
font-weight: 500;
display: none;
}
#dirty-badge.visible { display: inline; }
/* Dark mode overrides (applied via body.dark) */
body.dark { background: #1e1e1e; }
body.dark #excalidraw-toolbar button {
background: #2d2d2d;
border-color: #555;
color: #ddd;
}
body.dark #excalidraw-toolbar button:hover { background: #3d3d3d; }
body.dark #excalidraw-toolbar button.primary {
background: #6965db;
color: #fff;
}
body.dark #dirty-badge { color: #e0a36b; }
/* Loading state */
#loading {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
color: #888;
z-index: 999;
}
body.dark #loading { color: #aaa; }
#loading.hidden { display: none; }
</style>
</head>
<body>
<div id="loading">Loading Excalidraw…</div>
<div id="root"></div>
<div id="excalidraw-toolbar">
<span id="dirty-badge">● Modified</span>
<button id="btn-save" class="primary" title="Save (Ctrl+S)">💾 Save</button>
<button id="btn-export-png" title="Export PNG">🖼 PNG</button>
<button id="btn-export-svg" title="Export SVG">📐 SVG</button>
</div>
<!-- Excalidraw is loaded from esm.sh WITHOUT the `?alias=react:…` query.
The alias gets propagated by esm.sh to every transitive dependency; for
range-version deps (e.g. jotai@>=2.9.2) esm.sh then tries to rebuild the
range + alias variant and times out (HTTP 408) → the whole import fails →
the diagram stays stuck on "Loading Excalidraw…". Dropping the alias lets
esm.sh resolve a consistent React 19 (Excalidraw 0.18's default), which
loads reliably. -->
<script type="module">
import React from "https://esm.sh/react@^19.2.0?target=es2022";
import { createRoot } from "https://esm.sh/react-dom@^19.2.0/client?target=es2022";
import { decompressFromBase64 } from "https://esm.sh/[email protected]";
import * as ExcalidrawLib from "https://esm.sh/@excalidraw/[email protected]";
window.React = React;
window.EXCALIDRAW_ASSET_PATH = "https://esm.sh/@excalidraw/[email protected]/dist/prod/";
window.ExcalidrawLib = ExcalidrawLib;
const { Excalidraw, exportToBlob, exportToSvg } = ExcalidrawLib;
// --- State ---
let excalidrawAPI = null;
let isDirty = false;
let currentTheme = "light";
let saveTimeout = null;
// --- DOM refs ---
const loadingEl = document.getElementById("loading");
const dirtyBadge = document.getElementById("dirty-badge");
const btnSave = document.getElementById("btn-save");
const btnExportPng = document.getElementById("btn-export-png");
const btnExportSvg = document.getElementById("btn-export-svg");
// --- postMessage helpers ---
function sendToParent(msg) {
window.parent.postMessage(msg, "*");
}
// Signal that the iframe is loaded and ready to receive init data
sendToParent({ type: "ready" });
function markDirty() {
if (!isDirty) {
isDirty = true;
dirtyBadge.classList.add("visible");
sendToParent({ type: "modified", dirty: true });
}
}
function markClean() {
if (isDirty) {
isDirty = false;
dirtyBadge.classList.remove("visible");
sendToParent({ type: "modified", dirty: false });
}
}
function triggerSave() {
if (!excalidrawAPI) return;
const elements = excalidrawAPI.getSceneElements();
const appState = excalidrawAPI.getAppState();
const files = excalidrawAPI.getFiles();
sendToParent({
type: "save",
data: {
type: "excalidraw",
version: 2,
elements: elements,
appState: appState,
files: files,
},
});
markClean();
btnSave.textContent = "💾 Saved!";
setTimeout(() => { btnSave.textContent = "💾 Save"; }, 1200);
}
function setTheme(theme) {
currentTheme = theme;
document.body.classList.toggle("dark", theme === "dark");
if (excalidrawAPI) {
excalidrawAPI.updateScene({ appState: { theme: theme } });
}
}
// --- Excalidraw component ---
function App({ initialData, theme }) {
const [appState, setAppState] = React.useState(null);
// Excalidraw 0.18 exposes its imperative API through the `excalidrawAPI`
// prop, called with the API object once mounted (NOT the legacy
// `excalidrawRef` name). Without this the loading overlay never hides
// and save/export stay dead.
const onReady = React.useCallback((api) => {
if (api) {
excalidrawAPI = api;
loadingEl.classList.add("hidden");
if (theme === "dark") {
api.updateScene({ appState: { theme: "dark" } });
}
}
}, [theme]);
const onChange = React.useCallback((elements, state, files) => {
markDirty();
}, []);
return React.createElement(Excalidraw, {
excalidrawAPI: onReady,
initialData: initialData,
onChange: onChange,
theme: theme,
UIOptions: {
canvasActions: {
export: false,
loadScene: false,
saveToActiveFile: false,
saveAsImage: false,
},
},
});
}
// --- Message listener ---
window.addEventListener("message", (event) => {
const msg = event.data;
if (!msg || typeof msg.type !== "string") return;
switch (msg.type) {
case "init":
if (msg.data) {
let elements, appState, files;
// Handle compressed .excalidraw.md format
if (msg.data.compressed) {
try {
const decompressed = decompressFromBase64(msg.data.compressed);
const parsed = JSON.parse(decompressed);
elements = parsed.elements || [];
appState = parsed.appState || {};
files = parsed.files || {};
} catch(e) {
console.error("Failed to decompress Excalidraw data:", e);
elements = []; appState = {}; files = {};
}
} else {
elements = msg.data.elements || [];
appState = msg.data.appState || {};
files = msg.data.files || {};
}
const initialData = { elements, appState, files };
currentTheme = msg.theme || "light";
setTheme(currentTheme);
try {
if (typeof createRoot !== "function") {
throw new Error("createRoot is not available — check react-dom/client import");
}
createRoot(document.getElementById("root")).render(
React.createElement(App, {
initialData: initialData,
theme: currentTheme,
})
);
} catch (renderErr) {
console.error("Excalidraw render failed:", renderErr);
loadingEl.textContent = "Error loading Excalidraw: " + renderErr.message;
loadingEl.style.color = "#f44336";
loadingEl.classList.remove("hidden");
}
}
break;
case "theme":
setTheme(msg.theme || "light");
break;
case "requestSave":
triggerSave();
break;
}
});
// --- Keyboard shortcuts ---
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
triggerSave();
}
});
// --- Toolbar buttons ---
btnSave.addEventListener("click", triggerSave);
btnExportPng.addEventListener("click", async () => {
if (!excalidrawAPI) return;
try {
const blob = await exportToBlob({
elements: excalidrawAPI.getSceneElements(),
appState: excalidrawAPI.getAppState(),
files: excalidrawAPI.getFiles(),
mimeType: "image/png",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "diagram.png";
a.click();
URL.revokeObjectURL(url);
} catch (err) {
console.error("PNG export failed:", err);
}
});
btnExportSvg.addEventListener("click", async () => {
if (!excalidrawAPI) return;
try {
const svg = await exportToSvg({
elements: excalidrawAPI.getSceneElements(),
appState: excalidrawAPI.getAppState(),
files: excalidrawAPI.getFiles(),
});
const blob = new Blob([svg.outerHTML], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "diagram.svg";
a.click();
URL.revokeObjectURL(url);
} catch (err) {
console.error("SVG export failed:", err);
}
});
</script>
</body>
</html>