Files
ObsiGate/frontend/js/excalidraw-viewer.js
T
bruno 1b5319fde1
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
feat(excalidraw): support complet des fichiers .excalidraw (#78)
- 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
2026-07-29 10:55:00 -04:00

153 lines
4.7 KiB
JavaScript

/**
* 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 };