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

- 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:
2026-07-29 10:55:00 -04:00
parent ff38dd086e
commit 1b5319fde1
8 changed files with 664 additions and 1 deletions
+292
View File
@@ -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>
+152
View File
@@ -0,0 +1,152 @@
/**
* Excalidraw Viewer — iframe-based diagram editor integration for ObsiGate.
*
* Renders .excalidraw files in an iframe that loads the standalone
* excalidraw-editor.html. Communication between parent and iframe
* via postMessage:
*
* Parent → Iframe: init {data, theme} | theme {theme} | requestSave
* Iframe → Parent: ready | save {data} | modified {dirty}
*/
import { api } from './auth.js';
import { showToast } from './ui.js';
// Track active Excalidraw iframes by a unique id
const _activeEditors = new Map();
let _editorIdCounter = 0;
/**
* Render an Excalidraw editor inside the given container element.
*
* @param {HTMLElement} container - DOM element to host the iframe (usually content-area)
* @param {object} data - File data from /api/file/{vault} (must have is_excalidraw: true)
* @param {string} vaultName
* @param {string} filePath
* @param {object} [opts]
* @param {boolean} [opts.readOnly=false] - If true, hide save button in iframe
*/
export function renderExcalidraw(container, data, vaultName, filePath, opts = {}) {
const editorId = `excalidraw-${++_editorIdCounter}`;
// Build the iframe
const iframe = document.createElement('iframe');
iframe.id = editorId;
iframe.src = '/static/excalidraw-editor.html';
iframe.sandbox.add('allow-scripts');
iframe.sandbox.add('allow-same-origin');
iframe.style.cssText = 'width:100%;height:100%;border:none;';
// Clean container and insert iframe
container.innerHTML = '';
container.appendChild(iframe);
// Track this editor instance
const editorState = {
id: editorId,
iframe,
vault: vaultName,
path: filePath,
isDirty: false,
ready: false,
saveTimer: null,
};
_activeEditors.set(editorId, editorState);
// Get current theme
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
// Wait for iframe to load, then send init
iframe.addEventListener('load', () => {
// Send init message when iframe sends 'ready'
// (iframe may load before React mounts, so we wait for the ready signal)
// We set up the message listener before sending anything
});
// Handle messages from iframe
window.addEventListener('message', (event) => {
// Verify source is our iframe
if (event.source !== iframe.contentWindow) return;
const msg = event.data;
if (!msg || typeof msg.type !== 'string') return;
switch (msg.type) {
case 'ready':
// Iframe is mounted — send initial data
editorState.ready = true;
iframe.contentWindow.postMessage({
type: 'init',
data: data.excalidraw_data || {
elements: [],
appState: { viewBackgroundColor: '#ffffff' },
files: {},
},
theme: theme,
}, '*');
break;
case 'save':
if (msg.data) {
_saveExcalidrawFile(editorState, msg.data);
}
break;
case 'modified':
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;
}
});
}
/**
* Save Excalidraw data back to the server.
*/
async function _saveExcalidrawFile(state, excalidrawData) {
try {
const jsonStr = JSON.stringify(excalidrawData, null, 2);
await api(`/api/file/${encodeURIComponent(state.vault)}/save?path=${encodeURIComponent(state.path)}&backup=false`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: jsonStr }),
});
// Toast is handled by the caller or we show a quick one
showToast('Excalidraw saved', 'success');
} catch (err) {
console.error('[Excalidraw] Save failed:', err);
showToast('Failed to save Excalidraw: ' + (err.message || 'unknown error'), 'error');
}
}
/**
* Notify all active Excalidraw editors of a theme change.
*/
export function notifyExcalidrawThemeChange(theme) {
_activeEditors.forEach((state) => {
if (state.ready && state.iframe.contentWindow) {
state.iframe.contentWindow.postMessage({ type: 'theme', 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);
_activeEditors.delete(editorId);
}
}
export default { renderExcalidraw, notifyExcalidrawThemeChange, destroyExcalidrawEditor };
+1
View File
@@ -776,6 +776,7 @@ const FileOperations = {
<option value=".py">Python (.py)</option>
<option value=".js">JavaScript (.js)</option>
<option value=".json">JSON (.json)</option>
<option value=".excalidraw">Excalidraw (.excalidraw)</option>
<option value=".yaml">YAML (.yaml)</option>
<option value=".sh">Shell (.sh)</option>
<option value=".ps1">PowerShell (.ps1)</option>
+1
View File
@@ -79,6 +79,7 @@ const EXT_ICONS = {
".xml": "file-code",
".csv": "table",
".tsv": "table",
".excalidraw": "pen-tool",
".sql": "database",
".db": "database",
".sqlite": "database",
+7
View File
@@ -7,6 +7,7 @@ import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTr
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
import { initDashboardTabs } from './sync.js';
import { renderMermaidBlocks } from './mermaid-viewer.js';
import { renderExcalidraw } from './excalidraw-viewer.js';
import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, DashboardSharedWidget, DashboardConflictsWidget } from './dashboard.js';
import { openShareDialog } from './config.js';
import { cacheViewedFile, getCachedFile } from './offline.js';
@@ -448,6 +449,12 @@ export function renderFile(data) {
return;
}
// Handle Excalidraw — render in iframe editor
if (data.is_excalidraw) {
renderExcalidraw(area, data, data.vault, data.path);
return;
}
// Handle images
if (data.is_image) {
const imgUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;