feat(excalidraw): support complet des fichiers .excalidraw (#78)
CI / lint (push) Failing after 9s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / e2e (push) Has been skipped
CI / security (push) Successful in 13s
Desktop Build / build-windows (push) Has been cancelled
Desktop Build / build-linux (push) Has been cancelled
CI / lint (push) Failing after 9s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / e2e (push) Has been skipped
CI / security (push) Successful in 13s
Desktop Build / build-windows (push) Has been cancelled
Desktop Build / build-linux (push) Has been cancelled
- Backend: ajout .excalidraw dans SUPPORTED_EXTENSIONS + squelette JSON creation - Backend: détection is_excalidraw dans api_file() + FileContentResponse - Frontend: icône pen-tool dans EXT_ICONS - Frontend: ajout dans la modale de création (ui.js) - Frontend: excalidraw-editor.html (iframe autonome, React+Excalidraw via esm.sh) - Frontend: excalidraw-viewer.js (postMessage, auto-save 2s, thème) - Frontend: dispatch dans viewer.js (renderFile) - Tests: 6 tests (détection, création squelette, fallback, tree, roundtrip) - 359/359 tests passent
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
|
||||
<!-- Import React + ReactDOM (Excalidraw peer dependencies) -->
|
||||
<script type="module">
|
||||
import React from "https://esm.sh/react@18";
|
||||
import ReactDOM from "https://esm.sh/react-dom@18";
|
||||
window.React = React;
|
||||
window.ReactDOM = ReactDOM;
|
||||
</script>
|
||||
|
||||
<!-- Configure Excalidraw asset path BEFORE import -->
|
||||
<script type="module">
|
||||
window.EXCALIDRAW_ASSET_PATH = "https://esm.sh/@excalidraw/[email protected]/dist/prod/";
|
||||
import * as ExcalidrawLib from "https://esm.sh/@excalidraw/[email protected]";
|
||||
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, "*");
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const excalidrawRef = React.useCallback((api) => {
|
||||
if (api) {
|
||||
excalidrawAPI = api;
|
||||
loadingEl.classList.add("hidden");
|
||||
sendToParent({ type: "ready" });
|
||||
if (theme === "dark") {
|
||||
api.updateScene({ appState: { theme: "dark" } });
|
||||
}
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const onChange = React.useCallback((elements, state, files) => {
|
||||
markDirty();
|
||||
}, []);
|
||||
|
||||
return React.createElement(Excalidraw, {
|
||||
excalidrawRef: excalidrawRef,
|
||||
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) {
|
||||
const initialData = {
|
||||
elements: msg.data.elements || [],
|
||||
appState: msg.data.appState || {},
|
||||
files: msg.data.files || {},
|
||||
};
|
||||
currentTheme = msg.theme || "light";
|
||||
setTheme(currentTheme);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
React.createElement(App, {
|
||||
initialData: initialData,
|
||||
theme: currentTheme,
|
||||
})
|
||||
);
|
||||
}
|
||||
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>
|
||||
Reference in New Issue
Block a user