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