Files
ObsiGate/frontend/js/excalidraw-viewer.js
T
bruno 0cbbbf260a
CI / lint (push) Successful in 54s
CI / security (push) Successful in 38s
CI / test (push) Successful in 1m7s
CI / build (push) Successful in 33s
CI / e2e (push) Failing after 10m52s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
fix(search,excalidraw,e2e): recherche cassee + corruption .excalidraw.md + tests E2E #78
- frontend/js/search.js : onSearchFilter appelee avec (vault, query, results) — renvoyait undefined, cassait le rendu et basculait sur la recherche offline (0 resultat). Regression plugins depuis 30f2df3.
- frontend/excalidraw-editor.html + js/excalidraw-viewer.js : ignorer onChange au montage (plus d'auto-save spurieux qui reecrivait .excalidraw.md en JSON brut) + preservation du format Obsidian frontmatter + compressed-json a la sauvegarde.
- frontend/js/app.js : __OBSIGATE_BOOTED pose apres init() (splash + E2E attendent les handlers lies).
- tests/e2e : goHome/login attendent le boot ; excalidraw.spec.js reecrit avec les selecteurs reels (modal, context menu, canvas.first()).
- ROADMAP : C8 (creation via menu contextuel) marque fait.
- Verifie : 86/86 E2E chromium-desktop, 599 backend, ruff, validate-imports, JSDOM.
2026-09-10 22:51:08 -04:00

165 lines
5.5 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?v=' + Date.now();
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;
// Support both pure .excalidraw and compressed .excalidraw.md
const initData = data.excalidraw_data || {};
if (data.excalidraw_data_compressed) {
initData.compressed = data.excalidraw_data_compressed;
}
iframe.contentWindow.postMessage({
type: 'init',
data: initData,
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 isMd = (state.path || '').toLowerCase().endsWith('.excalidraw.md');
let content;
if (isMd && excalidrawData && excalidrawData.compressed) {
// Preserve the Obsidian Excalidraw plugin format: frontmatter +
// compressed-json block. Writing raw JSON here would destroy the
// .excalidraw.md file (the plugin would no longer recognize it).
content = `---\nexcalidraw-plugin: parsed\n---\n\n\`\`\`compressed-json\n${excalidrawData.compressed}\n\`\`\`\n`;
} else {
// Transport-only field must not leak into the pure JSON format.
const { compressed, ...scene } = excalidrawData || {};
content = JSON.stringify(scene, 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 }),
});
// 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 };