440 lines
17 KiB
HTML
440 lines
17 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>
|
|
<!-- 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; }
|
|
|
|
/* 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;
|
|
right: 0;
|
|
top: 45%;
|
|
z-index: 1000;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
align-items: center;
|
|
}
|
|
#excalidraw-toolbar button {
|
|
width: 34px;
|
|
height: 34px;
|
|
padding: 0;
|
|
border: 1px solid #d0d0d0;
|
|
border-radius: 6px;
|
|
background: #ffffff;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
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;
|
|
color: #fff;
|
|
border-color: #6965db;
|
|
}
|
|
#excalidraw-toolbar button.primary:hover { background: #5b57c4; }
|
|
|
|
/* Dirty indicator (small dot above the buttons) */
|
|
#dirty-badge {
|
|
font-size: 12px;
|
|
line-height: 1;
|
|
color: #e07b39;
|
|
font-weight: 700;
|
|
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" 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.
|
|
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, compressToBase64 } 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;
|
|
// Excalidraw fires onChange on initial mount; ignore those so simply
|
|
// opening a diagram does not trigger a spurious autosave (which would
|
|
// rewrite a .excalidraw.md file as raw JSON and destroy its format).
|
|
let ignoreChanges = true;
|
|
// Source format: "md" for the Obsidian .excalidraw.md (compressed-json).
|
|
let sourceIsMd = false;
|
|
|
|
// --- 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");
|
|
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) {
|
|
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();
|
|
const data = {
|
|
type: "excalidraw",
|
|
version: 2,
|
|
elements: elements,
|
|
appState: appState,
|
|
files: files,
|
|
};
|
|
// Preserve the Obsidian .excalidraw.md format: ship the compressed
|
|
// scene so the parent can rewrite the frontmatter + compressed-json block.
|
|
if (sourceIsMd) {
|
|
try {
|
|
data.compressed = compressToBase64(JSON.stringify(data));
|
|
} catch (e) {
|
|
console.error("Failed to compress Excalidraw scene:", e);
|
|
}
|
|
}
|
|
sendToParent({ type: "save", data });
|
|
markClean();
|
|
btnSave.innerHTML = CHECK_ICON;
|
|
setTimeout(() => { btnSave.innerHTML = SAVE_ICON; }, 1200);
|
|
}
|
|
|
|
function setTheme(theme) {
|
|
currentTheme = theme;
|
|
document.body.classList.toggle("dark", theme === "dark");
|
|
if (excalidrawAPI) {
|
|
excalidrawAPI.updateScene({ appState: { theme: theme } });
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// `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" } });
|
|
}
|
|
// 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();
|
|
}, []);
|
|
|
|
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) {
|
|
sourceIsMd = true;
|
|
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 || {};
|
|
}
|
|
appState = sanitizeAppState(appState);
|
|
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);
|
|
}
|
|
});
|
|
|
|
// --- 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>
|