fix: Editeur Excalidraw - diagramme vide (CSS + appState) et auto-save pendant l'edition (BUG-064, BUG-065)

This commit is contained in:
2026-09-18 08:59:54 -04:00
parent 7f0f64a42e
commit 2bd9dd7535
19 changed files with 436 additions and 75 deletions
+112 -18
View File
@@ -7,34 +7,48 @@
<meta http-equiv="Expires" content="0">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Excalidraw Editor</title>
<!-- Excalidraw's stylesheet MUST be loaded: without it the editor is
unstyled AND `.excalidraw` has no fixed height, so Excalidraw's
ResizeObserver feedback loop grows the canvas to the 2^25 hard cap and
the scene renders blank. Loaded from esm.sh (same origin as the JS
modules, already allowed by `font-src` for the relative font URLs). -->
<link rel="stylesheet" href="https://esm.sh/@excalidraw/[email protected]/dist/prod/index.css">
<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 */
/* Icon-only toolbar, vertical, flush against the right edge:
right edge at 100% of the viewport width, group starts at 45% of the
viewport height from the top. */
#excalidraw-toolbar {
position: fixed;
top: 8px;
right: 12px;
right: 0;
top: 45%;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
}
#excalidraw-toolbar button {
padding: 5px 10px;
width: 34px;
height: 34px;
padding: 0;
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;
justify-content: center;
transition: background 0.15s;
}
#excalidraw-toolbar button svg {
width: 18px;
height: 18px;
display: block;
}
#excalidraw-toolbar button:hover { background: #f0f0f0; }
#excalidraw-toolbar button.primary {
background: #6965db;
@@ -43,11 +57,12 @@
}
#excalidraw-toolbar button.primary:hover { background: #5b57c4; }
/* Dirty indicator */
/* Dirty indicator (small dot above the buttons) */
#dirty-badge {
font-size: 11px;
font-size: 12px;
line-height: 1;
color: #e07b39;
font-weight: 500;
font-weight: 700;
display: none;
}
#dirty-badge.visible { display: inline; }
@@ -86,10 +101,11 @@
<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>
<span id="dirty-badge" title="Modified">●</span>
<button id="btn-save" class="primary" title="Save (Ctrl+S)" aria-label="Save"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg></button>
<button id="btn-export-png" title="Export PNG" aria-label="Export PNG"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg></button>
<button id="btn-export-svg" title="Export SVG" aria-label="Export SVG"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 7a24 24 0 0 1 0 10M4.5 7a24 24 0 0 0 0 10M7 19.5a24 24 0 0 1 10 0M7 4.5a24 24 0 0 0 10 0"/><rect x="2" y="2" width="5" height="5" rx="1"/><rect x="17" y="2" width="5" height="5" rx="1"/><rect x="17" y="17" width="5" height="5" rx="1"/><rect x="2" y="17" width="5" height="5" rx="1"/></svg></button>
<button id="btn-fullscreen" title="Fullscreen" aria-label="Fullscreen"></button>
</div>
<!-- Excalidraw is loaded from esm.sh WITHOUT the `?alias=react:…` query.
@@ -129,6 +145,11 @@
const btnSave = document.getElementById("btn-save");
const btnExportPng = document.getElementById("btn-export-png");
const btnExportSvg = document.getElementById("btn-export-svg");
const btnFullscreen = document.getElementById("btn-fullscreen");
// Icon swapped in on a successful save (floppy → checkmark → floppy).
const SAVE_ICON = btnSave.innerHTML;
const CHECK_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
// --- postMessage helpers ---
function sendToParent(msg) {
@@ -177,8 +198,8 @@
}
sendToParent({ type: "save", data });
markClean();
btnSave.textContent = "💾 Saved!";
setTimeout(() => { btnSave.textContent = "💾 Save"; }, 1200);
btnSave.innerHTML = CHECK_ICON;
setTimeout(() => { btnSave.innerHTML = SAVE_ICON; }, 1200);
}
function setTheme(theme) {
@@ -189,9 +210,45 @@
}
}
// Excalidraw serializes its Map-typed appState fields (notably
// `collaborators`) to plain JSON objects on save — that is what files
// exported by the Excalidraw app / Obsidian plugin contain. Feeding such a
// plain object back through `initialData.appState` makes Excalidraw 0.18
// call `.forEach()` on it and crash with
// "e.appState.collaborators.forEach is not a function", leaving the canvas
// blank. Restore the expected Map shape (and drop anything unusable).
function sanitizeAppState(appState) {
if (!appState || typeof appState !== "object") return {};
// Viewport geometry is computed by Excalidraw from the container size.
// Files exported by the Excalidraw app carry whatever the *source* window
// measured (Obsidian pane, browser tab, …) and can contain absurd values
// (e.g. `height: 22369622`). Importing them makes Excalidraw size its
// canvas beyond the browser limit, so the scene renders off-screen /
// blank. Drop them and let Excalidraw recompute.
for (const key of ["width", "height", "offsetLeft", "offsetTop"]) {
delete appState[key];
}
if (appState.collaborators && !(appState.collaborators instanceof Map)) {
try {
appState.collaborators = new Map(Object.entries(appState.collaborators));
} catch (e) {
appState.collaborators = new Map();
}
}
return appState;
}
// Signature of the drawn content only. Excalidraw's onChange also fires for
// appState-only changes (resize, fullscreen, zoom, scroll); those must not
// flag the diagram as modified.
function sceneSignature(elements) {
return (elements || []).map((el) => `${el.id}:${el.versionNonce}`).join("|");
}
// --- Excalidraw component ---
function App({ initialData, theme }) {
const [appState, setAppState] = React.useState(null);
const lastSigRef = React.useRef(null);
// Excalidraw 0.18 exposes its imperative API through the `excalidrawAPI`
// prop, called with the API object once mounted (NOT the legacy
@@ -204,13 +261,20 @@
if (theme === "dark") {
api.updateScene({ appState: { theme: "dark" } });
}
// Stop ignoring changes once the initial mount settles.
setTimeout(() => { ignoreChanges = false; }, 800);
// Stop ignoring changes once the initial mount settles, and snapshot
// the loaded scene so a later appState-only change is not "dirty".
setTimeout(() => {
ignoreChanges = false;
lastSigRef.current = sceneSignature(api.getSceneElements());
}, 800);
}
}, [theme]);
const onChange = React.useCallback((elements, state, files) => {
if (ignoreChanges) return;
const sig = sceneSignature(elements);
if (sig === lastSigRef.current) return;
lastSigRef.current = sig;
markDirty();
}, []);
@@ -257,6 +321,7 @@
appState = msg.data.appState || {};
files = msg.data.files || {};
}
appState = sanitizeAppState(appState);
const initialData = { elements, appState, files };
currentTheme = msg.theme || "light";
setTheme(currentTheme);
@@ -340,6 +405,35 @@
console.error("SVG export failed:", err);
}
});
// --- Fullscreen ---
// The parent iframe is created with `allow="fullscreen" allowfullscreen`, so
// requesting fullscreen on this document makes the whole editor fill the
// screen. Excalidraw's ResizeObserver then grows the canvas to match.
const FS_ENTER = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>';
const FS_EXIT = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/></svg>';
function toggleFullscreen() {
if (!document.fullscreenElement) {
const req = document.documentElement.requestFullscreen
&& document.documentElement.requestFullscreen();
if (req && typeof req.catch === "function") {
req.catch((err) => console.warn("Fullscreen request failed:", err));
}
} else if (document.exitFullscreen) {
document.exitFullscreen();
}
}
document.addEventListener("fullscreenchange", () => {
const on = !!document.fullscreenElement;
btnFullscreen.innerHTML = on ? FS_EXIT : FS_ENTER;
btnFullscreen.title = on ? "Exit fullscreen" : "Fullscreen";
btnFullscreen.classList.toggle("active", on);
});
btnFullscreen.innerHTML = FS_ENTER;
btnFullscreen.addEventListener("click", toggleFullscreen);
</script>
</body>
</html>
+17 -14
View File
@@ -5,8 +5,11 @@
* excalidraw-editor.html. Communication between parent and iframe
* via postMessage:
*
* Parent → Iframe: init {data, theme} | theme {theme} | requestSave
* Parent → Iframe: init {data, theme} | theme {theme}
* Iframe → Parent: ready | save {data} | modified {dirty}
*
* Saves are explicit only (in-editor Save button / Ctrl+S): no autosave, so
* writing the file never triggers an `index_updated` reload of the viewer.
*/
import { api } from './auth.js';
@@ -35,7 +38,15 @@ export function renderExcalidraw(container, data, vaultName, filePath, opts = {}
iframe.src = '/static/excalidraw-editor.html?v=' + Date.now();
iframe.sandbox.add('allow-scripts');
iframe.sandbox.add('allow-same-origin');
// Let the editor's own Fullscreen button work (native Fullscreen API inside
// the sandboxed iframe).
iframe.setAttribute('allow', 'fullscreen');
iframe.setAttribute('allowfullscreen', '');
iframe.style.cssText = 'width:100%;height:100%;border:none;';
// Identify the owning document so sync.js can avoid re-rendering (and thus
// reloading) this iframe after its own save triggers an `index_updated`.
iframe.dataset.excalidrawVault = vaultName;
iframe.dataset.excalidrawPath = filePath;
// Clean container and insert iframe
container.innerHTML = '';
@@ -49,7 +60,6 @@ export function renderExcalidraw(container, data, vaultName, filePath, opts = {}
path: filePath,
isDirty: false,
ready: false,
saveTimer: null,
};
_activeEditors.set(editorId, editorState);
@@ -94,16 +104,11 @@ export function renderExcalidraw(container, data, vaultName, filePath, opts = {}
break;
case 'modified':
// No autosave: saving writes the file, which emits `index_updated` and
// reloads the viewer (visible page refresh) — and can interrupt the
// user mid-drawing. The scene is saved explicitly via the in-editor
// Save button or Ctrl+S. We only track the dirty state.
editorState.isDirty = msg.dirty === true;
// If dirty, start auto-save timer (2s debounce)
if (editorState.isDirty) {
if (editorState.saveTimer) clearTimeout(editorState.saveTimer);
editorState.saveTimer = setTimeout(() => {
if (editorState.isDirty && editorState.ready) {
iframe.contentWindow.postMessage({ type: 'requestSave' }, '*');
}
}, 2000);
}
break;
}
});
@@ -154,9 +159,7 @@ export function notifyExcalidrawThemeChange(theme) {
* Clean up an editor instance (e.g., when tab is closed).
*/
export function destroyExcalidrawEditor(editorId) {
const state = _activeEditors.get(editorId);
if (state) {
if (state.saveTimer) clearTimeout(state.saveTimer);
if (_activeEditors.has(editorId)) {
_activeEditors.delete(editorId);
}
}
+7
View File
@@ -693,6 +693,13 @@ async function reloadExternalWrite(vault, path, force = false) {
}
// Not editing: refresh the read view when it shows the written document.
if (state.currentVault === vault && state.currentPath === path) {
// Excalidraw owns its iframe: re-rendering would recreate it (visible page
// refresh) and discard the in-editor scene. Its own save already persisted
// the file, so there is nothing to reload here.
const openExcalidraw = Array.from(
document.querySelectorAll("iframe[data-excalidraw-path]")
).some((f) => f.dataset.excalidrawVault === vault && f.dataset.excalidrawPath === path);
if (openExcalidraw) return;
_invalidateActiveTabCache(vault, path);
openFile(vault, path);
}