feat(excalidraw): support complet des fichiers .excalidraw (#78)
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
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
- 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
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -776,6 +776,7 @@ const FileOperations = {
|
||||
<option value=".py">Python (.py)</option>
|
||||
<option value=".js">JavaScript (.js)</option>
|
||||
<option value=".json">JSON (.json)</option>
|
||||
<option value=".excalidraw">Excalidraw (.excalidraw)</option>
|
||||
<option value=".yaml">YAML (.yaml)</option>
|
||||
<option value=".sh">Shell (.sh)</option>
|
||||
<option value=".ps1">PowerShell (.ps1)</option>
|
||||
|
||||
@@ -79,6 +79,7 @@ const EXT_ICONS = {
|
||||
".xml": "file-code",
|
||||
".csv": "table",
|
||||
".tsv": "table",
|
||||
".excalidraw": "pen-tool",
|
||||
".sql": "database",
|
||||
".db": "database",
|
||||
".sqlite": "database",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTr
|
||||
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
|
||||
import { initDashboardTabs } from './sync.js';
|
||||
import { renderMermaidBlocks } from './mermaid-viewer.js';
|
||||
import { renderExcalidraw } from './excalidraw-viewer.js';
|
||||
import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, DashboardSharedWidget, DashboardConflictsWidget } from './dashboard.js';
|
||||
import { openShareDialog } from './config.js';
|
||||
import { cacheViewedFile, getCachedFile } from './offline.js';
|
||||
@@ -448,6 +449,12 @@ export function renderFile(data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Excalidraw — render in iframe editor
|
||||
if (data.is_excalidraw) {
|
||||
renderExcalidraw(area, data, data.vault, data.path);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle images
|
||||
if (data.is_image) {
|
||||
const imgUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
|
||||
|
||||
Reference in New Issue
Block a user