/** * 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} * 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'; 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 = '/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 = ''; container.appendChild(iframe); // Track this editor instance const editorState = { id: editorId, iframe, vault: vaultName, path: filePath, isDirty: false, ready: false, }; _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': // 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; 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) { if (_activeEditors.has(editorId)) { _activeEditors.delete(editorId); } } export default { renderExcalidraw, notifyExcalidrawThemeChange, destroyExcalidrawEditor };