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:
@@ -63,6 +63,7 @@ SUPPORTED_EXTENSIONS = {
|
|||||||
".java", ".c", ".cpp", ".h", ".hpp", ".cs", ".go", ".rs", ".rb",
|
".java", ".c", ".cpp", ".h", ".hpp", ".cs", ".go", ".rs", ".rb",
|
||||||
".php", ".sql", ".r", ".m", ".swift", ".kt",
|
".php", ".sql", ".r", ".m", ".swift", ".kt",
|
||||||
".dockerfile", ".makefile", ".cmake",
|
".dockerfile", ".makefile", ".cmake",
|
||||||
|
".excalidraw",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+44
-1
@@ -118,6 +118,15 @@ class FileContentResponse(BaseModel):
|
|||||||
is_markdown: bool = Field(description="Whether the file is markdown")
|
is_markdown: bool = Field(description="Whether the file is markdown")
|
||||||
unsupported: bool | None = Field(default=False, description="True for binary/unsupported files")
|
unsupported: bool | None = Field(default=False, description="True for binary/unsupported files")
|
||||||
size_bytes: int | None = Field(default=None, description="File size in bytes (for unsupported files)")
|
size_bytes: int | None = Field(default=None, description="File size in bytes (for unsupported files)")
|
||||||
|
is_pdf: bool | None = Field(default=None, description="True for PDF files")
|
||||||
|
is_image: bool | None = Field(default=None, description="True for image files")
|
||||||
|
is_csv: bool | None = Field(default=None, description="True for CSV files")
|
||||||
|
is_json: bool | None = Field(default=None, description="True for JSON files")
|
||||||
|
is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram files")
|
||||||
|
excalidraw_data: dict[str, Any] | None = Field(default=None, description="Excalidraw diagram data (elements, appState, files)")
|
||||||
|
pdf_metadata: dict[str, Any] | None = Field(default=None, description="PDF metadata")
|
||||||
|
pdf_toc: list[dict[str, Any]] | None = Field(default=None, description="PDF table of contents")
|
||||||
|
image_mime: str | None = Field(default=None, description="MIME type for image files")
|
||||||
|
|
||||||
|
|
||||||
class FileRawResponse(BaseModel):
|
class FileRawResponse(BaseModel):
|
||||||
@@ -1793,8 +1802,13 @@ async def api_file_create(
|
|||||||
# Create parent directories if needed
|
# Create parent directories if needed
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# For .excalidraw files, inject skeleton JSON if content is empty
|
||||||
|
content = body.content
|
||||||
|
if ext == ".excalidraw" and not content.strip():
|
||||||
|
content = '{"type":"excalidraw","version":2,"elements":[],"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}'
|
||||||
|
|
||||||
# Create file with initial content
|
# Create file with initial content
|
||||||
file_path.write_text(body.content, encoding="utf-8")
|
file_path.write_text(content, encoding="utf-8")
|
||||||
logger.info(f"File created: {vault_name}/{body.path}")
|
logger.info(f"File created: {vault_name}/{body.path}")
|
||||||
|
|
||||||
# Update index
|
# Update index
|
||||||
@@ -2489,6 +2503,35 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
|||||||
"is_markdown": False, "is_json": True,
|
"is_markdown": False, "is_json": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# === Excalidraw: diagram editor ===
|
||||||
|
if ext == ".excalidraw":
|
||||||
|
import json as json_mod
|
||||||
|
try:
|
||||||
|
parsed = json_mod.loads(raw)
|
||||||
|
except json_mod.JSONDecodeError:
|
||||||
|
parsed = None
|
||||||
|
if parsed and parsed.get("type") == "excalidraw":
|
||||||
|
return {
|
||||||
|
"vault": vault_name,
|
||||||
|
"path": path,
|
||||||
|
"title": parsed.get("appState", {}).get("name") or file_path.name,
|
||||||
|
"tags": [],
|
||||||
|
"frontmatter": {},
|
||||||
|
"html": "",
|
||||||
|
"raw_length": len(raw),
|
||||||
|
"extension": ext,
|
||||||
|
"is_markdown": False,
|
||||||
|
"is_excalidraw": True,
|
||||||
|
"excalidraw_data": {
|
||||||
|
"elements": parsed.get("elements", []),
|
||||||
|
"appState": parsed.get("appState", {}),
|
||||||
|
"files": parsed.get("files", {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Not a valid Excalidraw file — fall through to text viewer
|
||||||
|
pass
|
||||||
|
|
||||||
# === Plain text / other readable files ===
|
# === Plain text / other readable files ===
|
||||||
TEXT_EXTENSIONS = {".txt", ".log", ".yml", ".yaml", ".toml", ".ini", ".cfg",
|
TEXT_EXTENSIONS = {".txt", ".log", ".yml", ".yaml", ".toml", ".ini", ".cfg",
|
||||||
".sh", ".bash", ".py", ".js", ".ts", ".html", ".css",
|
".sh", ".bash", ".py", ".js", ".ts", ".html", ".css",
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Excalidraw Editor</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body, #root { width: 100%; height: 100%; overflow: hidden; }
|
||||||
|
body { background: #ffffff; }
|
||||||
|
|
||||||
|
/* Toolbar overlay in top-right corner */
|
||||||
|
#excalidraw-toolbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 8px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#excalidraw-toolbar button {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid #d0d0d0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
#excalidraw-toolbar button:hover { background: #f0f0f0; }
|
||||||
|
#excalidraw-toolbar button.primary {
|
||||||
|
background: #6965db;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #6965db;
|
||||||
|
}
|
||||||
|
#excalidraw-toolbar button.primary:hover { background: #5b57c4; }
|
||||||
|
|
||||||
|
/* Dirty indicator */
|
||||||
|
#dirty-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #e07b39;
|
||||||
|
font-weight: 500;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#dirty-badge.visible { display: inline; }
|
||||||
|
|
||||||
|
/* Dark mode overrides (applied via body.dark) */
|
||||||
|
body.dark { background: #1e1e1e; }
|
||||||
|
body.dark #excalidraw-toolbar button {
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-color: #555;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
body.dark #excalidraw-toolbar button:hover { background: #3d3d3d; }
|
||||||
|
body.dark #excalidraw-toolbar button.primary {
|
||||||
|
background: #6965db;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
body.dark #dirty-badge { color: #e0a36b; }
|
||||||
|
|
||||||
|
/* Loading state */
|
||||||
|
#loading {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
body.dark #loading { color: #aaa; }
|
||||||
|
#loading.hidden { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="loading">Loading Excalidraw…</div>
|
||||||
|
<div id="root"></div>
|
||||||
|
<div id="excalidraw-toolbar">
|
||||||
|
<span id="dirty-badge">● Modified</span>
|
||||||
|
<button id="btn-save" class="primary" title="Save (Ctrl+S)">💾 Save</button>
|
||||||
|
<button id="btn-export-png" title="Export PNG">🖼 PNG</button>
|
||||||
|
<button id="btn-export-svg" title="Export SVG">📐 SVG</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import React + ReactDOM (Excalidraw peer dependencies) -->
|
||||||
|
<script type="module">
|
||||||
|
import React from "https://esm.sh/react@18";
|
||||||
|
import ReactDOM from "https://esm.sh/react-dom@18";
|
||||||
|
window.React = React;
|
||||||
|
window.ReactDOM = ReactDOM;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Configure Excalidraw asset path BEFORE import -->
|
||||||
|
<script type="module">
|
||||||
|
window.EXCALIDRAW_ASSET_PATH = "https://esm.sh/@excalidraw/[email protected]/dist/prod/";
|
||||||
|
import * as ExcalidrawLib from "https://esm.sh/@excalidraw/[email protected]";
|
||||||
|
window.ExcalidrawLib = ExcalidrawLib;
|
||||||
|
|
||||||
|
const { Excalidraw, exportToBlob, exportToSvg } = ExcalidrawLib;
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let excalidrawAPI = null;
|
||||||
|
let isDirty = false;
|
||||||
|
let currentTheme = "light";
|
||||||
|
let saveTimeout = null;
|
||||||
|
|
||||||
|
// --- DOM refs ---
|
||||||
|
const loadingEl = document.getElementById("loading");
|
||||||
|
const dirtyBadge = document.getElementById("dirty-badge");
|
||||||
|
const btnSave = document.getElementById("btn-save");
|
||||||
|
const btnExportPng = document.getElementById("btn-export-png");
|
||||||
|
const btnExportSvg = document.getElementById("btn-export-svg");
|
||||||
|
|
||||||
|
// --- postMessage helpers ---
|
||||||
|
function sendToParent(msg) {
|
||||||
|
window.parent.postMessage(msg, "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
if (!isDirty) {
|
||||||
|
isDirty = true;
|
||||||
|
dirtyBadge.classList.add("visible");
|
||||||
|
sendToParent({ type: "modified", dirty: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markClean() {
|
||||||
|
if (isDirty) {
|
||||||
|
isDirty = false;
|
||||||
|
dirtyBadge.classList.remove("visible");
|
||||||
|
sendToParent({ type: "modified", dirty: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerSave() {
|
||||||
|
if (!excalidrawAPI) return;
|
||||||
|
const elements = excalidrawAPI.getSceneElements();
|
||||||
|
const appState = excalidrawAPI.getAppState();
|
||||||
|
const files = excalidrawAPI.getFiles();
|
||||||
|
sendToParent({
|
||||||
|
type: "save",
|
||||||
|
data: {
|
||||||
|
type: "excalidraw",
|
||||||
|
version: 2,
|
||||||
|
elements: elements,
|
||||||
|
appState: appState,
|
||||||
|
files: files,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
markClean();
|
||||||
|
btnSave.textContent = "💾 Saved!";
|
||||||
|
setTimeout(() => { btnSave.textContent = "💾 Save"; }, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTheme(theme) {
|
||||||
|
currentTheme = theme;
|
||||||
|
document.body.classList.toggle("dark", theme === "dark");
|
||||||
|
if (excalidrawAPI) {
|
||||||
|
excalidrawAPI.updateScene({ appState: { theme: theme } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Excalidraw component ---
|
||||||
|
function App({ initialData, theme }) {
|
||||||
|
const [appState, setAppState] = React.useState(null);
|
||||||
|
|
||||||
|
const excalidrawRef = React.useCallback((api) => {
|
||||||
|
if (api) {
|
||||||
|
excalidrawAPI = api;
|
||||||
|
loadingEl.classList.add("hidden");
|
||||||
|
sendToParent({ type: "ready" });
|
||||||
|
if (theme === "dark") {
|
||||||
|
api.updateScene({ appState: { theme: "dark" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const onChange = React.useCallback((elements, state, files) => {
|
||||||
|
markDirty();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return React.createElement(Excalidraw, {
|
||||||
|
excalidrawRef: excalidrawRef,
|
||||||
|
initialData: initialData,
|
||||||
|
onChange: onChange,
|
||||||
|
theme: theme,
|
||||||
|
UIOptions: {
|
||||||
|
canvasActions: {
|
||||||
|
export: false,
|
||||||
|
loadScene: false,
|
||||||
|
saveToActiveFile: false,
|
||||||
|
saveAsImage: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Message listener ---
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
const msg = event.data;
|
||||||
|
if (!msg || typeof msg.type !== "string") return;
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case "init":
|
||||||
|
if (msg.data) {
|
||||||
|
const initialData = {
|
||||||
|
elements: msg.data.elements || [],
|
||||||
|
appState: msg.data.appState || {},
|
||||||
|
files: msg.data.files || {},
|
||||||
|
};
|
||||||
|
currentTheme = msg.theme || "light";
|
||||||
|
setTheme(currentTheme);
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
React.createElement(App, {
|
||||||
|
initialData: initialData,
|
||||||
|
theme: currentTheme,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "theme":
|
||||||
|
setTheme(msg.theme || "light");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "requestSave":
|
||||||
|
triggerSave();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Keyboard shortcuts ---
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
triggerSave();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Toolbar buttons ---
|
||||||
|
btnSave.addEventListener("click", triggerSave);
|
||||||
|
|
||||||
|
btnExportPng.addEventListener("click", async () => {
|
||||||
|
if (!excalidrawAPI) return;
|
||||||
|
try {
|
||||||
|
const blob = await exportToBlob({
|
||||||
|
elements: excalidrawAPI.getSceneElements(),
|
||||||
|
appState: excalidrawAPI.getAppState(),
|
||||||
|
files: excalidrawAPI.getFiles(),
|
||||||
|
mimeType: "image/png",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "diagram.png";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("PNG export failed:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnExportSvg.addEventListener("click", async () => {
|
||||||
|
if (!excalidrawAPI) return;
|
||||||
|
try {
|
||||||
|
const svg = await exportToSvg({
|
||||||
|
elements: excalidrawAPI.getSceneElements(),
|
||||||
|
appState: excalidrawAPI.getAppState(),
|
||||||
|
files: excalidrawAPI.getFiles(),
|
||||||
|
});
|
||||||
|
const blob = new Blob([svg.outerHTML], { type: "image/svg+xml" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "diagram.svg";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("SVG export failed:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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=".py">Python (.py)</option>
|
||||||
<option value=".js">JavaScript (.js)</option>
|
<option value=".js">JavaScript (.js)</option>
|
||||||
<option value=".json">JSON (.json)</option>
|
<option value=".json">JSON (.json)</option>
|
||||||
|
<option value=".excalidraw">Excalidraw (.excalidraw)</option>
|
||||||
<option value=".yaml">YAML (.yaml)</option>
|
<option value=".yaml">YAML (.yaml)</option>
|
||||||
<option value=".sh">Shell (.sh)</option>
|
<option value=".sh">Shell (.sh)</option>
|
||||||
<option value=".ps1">PowerShell (.ps1)</option>
|
<option value=".ps1">PowerShell (.ps1)</option>
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ const EXT_ICONS = {
|
|||||||
".xml": "file-code",
|
".xml": "file-code",
|
||||||
".csv": "table",
|
".csv": "table",
|
||||||
".tsv": "table",
|
".tsv": "table",
|
||||||
|
".excalidraw": "pen-tool",
|
||||||
".sql": "database",
|
".sql": "database",
|
||||||
".db": "database",
|
".db": "database",
|
||||||
".sqlite": "database",
|
".sqlite": "database",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTr
|
|||||||
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
|
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
|
||||||
import { initDashboardTabs } from './sync.js';
|
import { initDashboardTabs } from './sync.js';
|
||||||
import { renderMermaidBlocks } from './mermaid-viewer.js';
|
import { renderMermaidBlocks } from './mermaid-viewer.js';
|
||||||
|
import { renderExcalidraw } from './excalidraw-viewer.js';
|
||||||
import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, DashboardSharedWidget, DashboardConflictsWidget } from './dashboard.js';
|
import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, DashboardSharedWidget, DashboardConflictsWidget } from './dashboard.js';
|
||||||
import { openShareDialog } from './config.js';
|
import { openShareDialog } from './config.js';
|
||||||
import { cacheViewedFile, getCachedFile } from './offline.js';
|
import { cacheViewedFile, getCachedFile } from './offline.js';
|
||||||
@@ -448,6 +449,12 @@ export function renderFile(data) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle Excalidraw — render in iframe editor
|
||||||
|
if (data.is_excalidraw) {
|
||||||
|
renderExcalidraw(area, data, data.vault, data.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle images
|
// Handle images
|
||||||
if (data.is_image) {
|
if (data.is_image) {
|
||||||
const imgUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
|
const imgUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Tests for Excalidraw file support in ObsiGate."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
EXCALIDRAW_SKELETON = {
|
||||||
|
"type": "excalidraw",
|
||||||
|
"version": 2,
|
||||||
|
"elements": [],
|
||||||
|
"appState": {"viewBackgroundColor": "#ffffff"},
|
||||||
|
"files": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
EXCALIDRAW_WITH_CONTENT = {
|
||||||
|
"type": "excalidraw",
|
||||||
|
"version": 2,
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"id": "rect-1",
|
||||||
|
"type": "rectangle",
|
||||||
|
"x": 100, "y": 200, "width": 300, "height": 150,
|
||||||
|
"angle": 0, "strokeColor": "#1e1e1e",
|
||||||
|
"backgroundColor": "#ffec99", "fillStyle": "solid",
|
||||||
|
"strokeWidth": 2, "strokeStyle": "solid",
|
||||||
|
"roughness": 1, "opacity": 100, "groupIds": [],
|
||||||
|
"roundness": {"type": 3},
|
||||||
|
"boundElements": None, "updated": 1,
|
||||||
|
"link": None, "locked": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "text-1", "type": "text",
|
||||||
|
"x": 150, "y": 260, "width": 200, "height": 30,
|
||||||
|
"angle": 0, "strokeColor": "#1e1e1e",
|
||||||
|
"backgroundColor": "transparent", "fillStyle": "solid",
|
||||||
|
"strokeWidth": 2, "strokeStyle": "solid",
|
||||||
|
"roughness": 1, "opacity": 100, "groupIds": [],
|
||||||
|
"roundness": None, "boundElements": None,
|
||||||
|
"updated": 1, "link": None, "locked": False,
|
||||||
|
"text": "Hello Excalidraw", "fontSize": 20,
|
||||||
|
"fontFamily": 1, "textAlign": "center",
|
||||||
|
"verticalAlign": "middle", "containerId": "rect-1",
|
||||||
|
"originalText": "Hello Excalidraw",
|
||||||
|
"autoResize": True, "lineHeight": 1.25,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"appState": {"viewBackgroundColor": "#ffffff", "name": "My Diagram"},
|
||||||
|
"files": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestExcalidrawDetection:
|
||||||
|
"""Test that the /api/file endpoint correctly detects .excalidraw files."""
|
||||||
|
|
||||||
|
VAULT = "TestVault"
|
||||||
|
|
||||||
|
def test_valid_excalidraw_file(self, client):
|
||||||
|
"""A valid .excalidraw file should return is_excalidraw: true."""
|
||||||
|
path = "diagram.excalidraw"
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/file/{self.VAULT}",
|
||||||
|
json={"path": path, "content": json.dumps(EXCALIDRAW_WITH_CONTENT)},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
resp = client.get(f"/api/file/{self.VAULT}", params={"path": path})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["is_excalidraw"] is True
|
||||||
|
assert "excalidraw_data" in data
|
||||||
|
assert len(data["excalidraw_data"]["elements"]) == 2
|
||||||
|
assert data["excalidraw_data"]["elements"][0]["type"] == "rectangle"
|
||||||
|
assert data["title"] == "My Diagram"
|
||||||
|
|
||||||
|
def test_empty_excalidraw_creation(self, client):
|
||||||
|
"""Creating a .excalidraw with empty content injects skeleton JSON."""
|
||||||
|
path = "empty.excalidraw"
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/file/{self.VAULT}",
|
||||||
|
json={"path": path, "content": ""},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
resp = client.get(f"/api/file/{self.VAULT}", params={"path": path})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["is_excalidraw"] is True
|
||||||
|
assert data["excalidraw_data"]["elements"] == []
|
||||||
|
assert data["excalidraw_data"]["appState"]["viewBackgroundColor"] == "#ffffff"
|
||||||
|
|
||||||
|
def test_invalid_json_excalidraw_fallback(self, client):
|
||||||
|
"""A .excalidraw with invalid JSON should show as text, not Excalidraw."""
|
||||||
|
path = "corrupt.excalidraw"
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/file/{self.VAULT}",
|
||||||
|
json={"path": path, "content": "this is not valid json!!!"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
resp = client.get(f"/api/file/{self.VAULT}", params={"path": path})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert not data.get("is_excalidraw")
|
||||||
|
|
||||||
|
def test_json_without_excalidraw_type(self, client):
|
||||||
|
"""A .excalidraw with valid JSON but wrong type falls through."""
|
||||||
|
path = "not-excalidraw.excalidraw"
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/file/{self.VAULT}",
|
||||||
|
json={"path": path, "content": '{"type":"something-else","data":[1,2,3]}'},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
resp = client.get(f"/api/file/{self.VAULT}", params={"path": path})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert not data.get("is_excalidraw")
|
||||||
|
|
||||||
|
def test_excalidraw_in_tree(self, client):
|
||||||
|
"""A .excalidraw file appears in the file tree."""
|
||||||
|
path = "tree-diagram.excalidraw"
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/file/{self.VAULT}",
|
||||||
|
json={"path": path, "content": json.dumps(EXCALIDRAW_SKELETON)},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
resp = client.get(f"/api/browse/{self.VAULT}", params={"path": ""})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
items = resp.json()["items"]
|
||||||
|
names = [i["name"] for i in items]
|
||||||
|
assert "tree-diagram.excalidraw" in names, f"Not found in: {names}"
|
||||||
|
|
||||||
|
def test_excalidraw_save_roundtrip(self, client):
|
||||||
|
"""Create, edit (PUT), and re-read an .excalidraw file."""
|
||||||
|
path = "roundtrip.excalidraw"
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
f"/api/file/{self.VAULT}",
|
||||||
|
json={"path": path, "content": ""},
|
||||||
|
)
|
||||||
|
|
||||||
|
modified = dict(EXCALIDRAW_SKELETON)
|
||||||
|
modified["elements"] = [{
|
||||||
|
"id": "ellipse-1", "type": "ellipse",
|
||||||
|
"x": 50, "y": 50, "width": 100, "height": 80,
|
||||||
|
"angle": 0, "strokeColor": "#1e1e1e",
|
||||||
|
"backgroundColor": "#a5d8ff", "fillStyle": "solid",
|
||||||
|
"strokeWidth": 2, "strokeStyle": "solid",
|
||||||
|
"roughness": 1, "opacity": 100, "groupIds": [],
|
||||||
|
"roundness": {"type": 2},
|
||||||
|
"boundElements": None, "updated": 1,
|
||||||
|
"link": None, "locked": False,
|
||||||
|
}]
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/file/{self.VAULT}/save",
|
||||||
|
params={"path": path, "backup": "false"},
|
||||||
|
json={"content": json.dumps(modified)},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
resp = client.get(f"/api/file/{self.VAULT}", params={"path": path})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["is_excalidraw"] is True
|
||||||
|
assert len(data["excalidraw_data"]["elements"]) == 1
|
||||||
|
assert data["excalidraw_data"]["elements"][0]["type"] == "ellipse"
|
||||||
Reference in New Issue
Block a user