Files
ObsiGate/frontend/js/excalidraw-viewer.js
T

168 lines
5.8 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}
* 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 };