feat: add test excalidraw page with deep linking support

- Added new test excalidraw page component and route with deep linking via ?tests=excalidraw
- Enhanced drawings editor to preserve frontmatter and normalize metadata when saving .excalidraw.md files
- Improved excalidraw file handling with better error handling and sanitization
- Updated sidebar and navigation to include test excalidraw option
- Fixed collaborators map serialization issue in excalidraw scene data
This commit is contained in:
2025-10-28 12:47:25 -04:00
parent e944f6645a
commit 5647b42d8a
16 changed files with 631 additions and 110 deletions
+3
View File
@@ -30,6 +30,7 @@
(markdownPlaygroundSelected)="setView('markdown-playground')"
(parametersOpened)="setView('parameters')"
(testsPanelRequested)="setView('tests-panel')"
(testsExcalidrawRequested)="setView('tests-excalidraw')"
(helpPageRequested)="openHelpPage()"
(noteCreated)="onNoteCreated($event)"
(noteCreatedAndSelected)="onNoteCreatedAndSelected($event)"
@@ -541,6 +542,8 @@
<app-parameters></app-parameters>
} @else if (activeView() === 'tests-panel') {
<app-tests-panel></app-tests-panel>
} @else if (activeView() === 'tests-excalidraw') {
<app-test-excalidraw-page></app-test-excalidraw-page>
} @else {
@if (activeView() === 'drawings') {
@if (currentDrawingPath()) {
+19 -2
View File
@@ -38,6 +38,8 @@ import { LayoutModule } from '@angular/cdk/layout';
import { ToastContainerComponent } from './app/shared/toast/toast-container.component';
import { ParametersPage } from './app/features/parameters/parameters.page';
import { TestsPanelComponent } from './app/features/tests/tests-panel.component';
import { TestExcalidrawPageComponent } from './app/features/tests/test-excalidraw-page.component';
import { Router } from '@angular/router';
// Types
import { FileMetadata, Note, TagInfo, VaultNode } from './types';
@@ -71,6 +73,7 @@ interface TocEntry {
ToastContainerComponent,
ParametersPage,
TestsPanelComponent,
TestExcalidrawPageComponent,
],
templateUrl: './app.component.simple.html',
styleUrls: ['./app.component.css'],
@@ -93,12 +96,13 @@ export class AppComponent implements OnInit, OnDestroy {
private readonly drawingsFiles = inject(DrawingsFileService);
private readonly urlState = inject(UrlStateService);
private readonly editorState = inject(EditorStateService);
private readonly router = inject(Router);
// --- State Signals ---
isSidebarOpen = signal<boolean>(true);
isOutlineOpen = signal<boolean>(false);
outlineTab = signal<'outline' | 'settings'>('outline');
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks' | 'drawings' | 'markdown-playground' | 'parameters' | 'tests-panel'>('files');
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks' | 'drawings' | 'markdown-playground' | 'parameters' | 'tests-panel' | 'tests-excalidraw'>('files');
currentDrawingPath = signal<string | null>(null);
selectedNoteId = signal<string>('');
sidebarSearchTerm = signal<string>('');
@@ -140,6 +144,13 @@ export class AppComponent implements OnInit, OnDestroy {
this.editorState.enterEditMode(note.filePath, content);
}
});
// Deep-link support for tests=excalidraw
try {
const sp = new URLSearchParams(window.location.search || '');
if (sp.get('tests') === 'excalidraw') {
this.activeView.set('tests-excalidraw');
}
} catch {}
}
onNoteCreatedAndSelected(event: { id: string; filePath: string }): void {
@@ -978,10 +989,16 @@ export class AppComponent implements OnInit, OnDestroy {
handle?.addEventListener('lostpointercapture', cleanup);
}
setView(view: 'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks' | 'drawings' | 'markdown-playground' | 'parameters' | 'tests-panel'): void {
setView(view: 'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks' | 'drawings' | 'markdown-playground' | 'parameters' | 'tests-panel' | 'tests-excalidraw'): void {
const previousView = this.activeView();
this.activeView.set(view);
this.sidebarSearchTerm.set('');
// Maintain deep-link for tests view
if (view === 'tests-excalidraw') {
this.router.navigate([], { queryParams: { tests: 'excalidraw' }, queryParamsHandling: 'merge', preserveFragment: true });
} else if (previousView === 'tests-excalidraw') {
this.router.navigate([], { queryParams: { tests: null }, queryParamsHandling: 'merge', preserveFragment: true });
}
// Log view changes
if (view === 'bookmarks' && previousView !== 'bookmarks') {
@@ -223,8 +223,28 @@ export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy
const isExMd = /\.excalidraw\.md$/i.test(this.path || '');
let result: { rev: string } | null = null;
if (isExMd) {
// Use same compression as creation: build Obsidian MD with ```compressed-json```
const md = this.excalIo.toObsidianMd(scene as any, null);
// Preserve existing frontmatter and normalize keys
let existing = '';
try { existing = await firstValueFrom(this.files.getText(this.path)); } catch {}
let fm = this.excalIo.extractFrontMatter(existing) || null;
if (fm) {
// Ensure excalidraw-plugin: parsed
if (!/excalidraw-plugin\s*:/i.test(fm)) {
fm = fm.replace(/^---\s*\n/, `---\nexcalidraw-plugin: parsed\n`);
} else {
fm = fm.replace(/excalidraw-plugin\s*:\s*.*?(\r?\n)/i, `excalidraw-plugin: parsed$1`);
}
// Update timestamp
const stamp = new Date().toISOString();
if (/^updated\s*:/mi.test(fm)) {
fm = fm.replace(/updated\s*:\s*.*?(\r?\n)/i, `updated: "${stamp}"$1`);
} else {
fm = fm.replace(/^---\s*\n/, `---\nupdated: "${stamp}"\n`);
}
} else {
fm = `---\nexcalidraw-plugin: parsed\nupdated: "${new Date().toISOString()}"\n---`;
}
const md = this.excalIo.toObsidianMd(scene as any, fm);
result = await firstValueFrom(this.files.putText(this.path, md));
} else {
// For .excalidraw or .json let server persist plain JSON
@@ -420,13 +440,20 @@ export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy
}
private prepareSceneData(scene: ExcalidrawScene) {
const incomingApp: any = (scene && typeof scene.appState === 'object' && scene.appState)
? { ...(scene.appState as any) }
: {};
// Excalidraw expects collaborators to be a Map at runtime; JSON files store object/array → remove it
if (incomingApp && typeof incomingApp === 'object' && 'collaborators' in incomingApp) {
try { delete incomingApp.collaborators; } catch {}
}
return {
elements: Array.isArray(scene.elements) ? scene.elements : [],
appState: {
viewBackgroundColor: '#1e1e1e',
currentItemFontFamily: 1,
theme: this.themeName(),
...(scene.appState || {})
...incomingApp
},
scrollToContent: true,
...(scene.files && { files: scene.files })
@@ -678,7 +705,7 @@ export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy
return null;
}
async exportPNG(): Promise<void> {
async exportPNG(withBackground: boolean = true): Promise<void> {
try {
const host = this.editorEl?.nativeElement;
if (!host || !this.excalidrawReady) {
@@ -687,7 +714,7 @@ export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy
return;
}
const blob = await this.previews.exportPNGFromElement(host, true);
const blob = await this.previews.exportPNGFromElement(host, withBackground);
if (!blob) {
console.error('Failed to generate PNG blob');
this.showToast('Export PNG indisponible', 'error');
@@ -705,7 +732,7 @@ export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy
}
}
async exportSVG(): Promise<void> {
async exportSVG(withBackground: boolean = true): Promise<void> {
try {
const host = this.editorEl?.nativeElement;
if (!host || !this.excalidrawReady) {
@@ -714,7 +741,7 @@ export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy
return;
}
const blob = await this.previews.exportSVGFromElement(host, true);
const blob = await this.previews.exportSVGFromElement(host, withBackground);
if (!blob) {
console.error('Failed to generate SVG blob');
this.showToast('Export SVG indisponible', 'error');
@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, map, tap } from 'rxjs';
import { ExcalidrawIoService } from './excalidraw-io.service';
export type ThemeName = 'light' | 'dark';
@@ -14,10 +15,29 @@ export interface ExcalidrawScene {
export class DrawingsFileService {
private etagCache = new Map<string, string>();
constructor(private http: HttpClient) {}
private http = inject(HttpClient);
private excalIo = inject(ExcalidrawIoService);
get(path: string): Observable<ExcalidrawScene> {
const url = `/api/files`;
const isMd = /\.excalidraw\.md$/i.test(path || '');
if (isMd) {
return this.http.get(url, {
params: { path },
observe: 'response',
responseType: 'text' as const,
}).pipe(
tap((res) => {
const etag = res.headers.get('ETag');
if (etag) this.etagCache.set(path, etag);
}),
map((res) => {
const md = res.body ?? '';
const parsed = this.excalIo.parseAny(String(md)) || { elements: [], appState: {}, files: {} };
return parsed as ExcalidrawScene;
})
);
}
return this.http.get<ExcalidrawScene>(url, {
params: { path: path },
observe: 'response'
@@ -30,6 +50,22 @@ export class DrawingsFileService {
);
}
// Fetch raw text content and record ETag
getText(path: string): Observable<string> {
const url = `/api/files`;
return this.http.get(url, {
params: { path },
observe: 'response',
responseType: 'text' as const,
}).pipe(
tap((res) => {
const etag = res.headers.get('ETag');
if (etag) this.etagCache.set(path, etag);
}),
map((res) => res.body ?? '')
);
}
put(path: string, scene: ExcalidrawScene): Observable<{ rev: string }> {
const url = `/api/files`;
const prev = this.etagCache.get(path);
@@ -18,10 +18,26 @@ export class ExcalidrawIoService {
*/
extractFrontMatter(md: string): string | null {
if (!md) return null;
const match = md.match(/^---\s*\n([\s\S]*?)\n---/);
const match = md.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/);
return match ? match[0] : null;
}
/**
* Remove fields that cause issues when (de)serializing between Excalidraw and Obsidian
*/
private sanitizeForSave(scene: ExcalidrawScene): ExcalidrawScene {
const app: any = scene?.appState && typeof scene.appState === 'object' ? { ...scene.appState } : {};
// Excalidraw expects collaborators as a Map at runtime; do not persist arbitrary object
if (app && typeof app === 'object' && 'collaborators' in app) {
try { delete app.collaborators; } catch {}
}
return {
elements: Array.isArray(scene?.elements) ? scene.elements : [],
appState: app,
files: (scene && typeof scene.files === 'object') ? scene.files : {}
};
}
/**
* Parse Obsidian Excalidraw markdown format
* Extracts compressed-json block and decompresses using LZ-String
@@ -29,14 +45,19 @@ export class ExcalidrawIoService {
parseObsidianMd(md: string): ExcalidrawScene | null {
if (!md || typeof md !== 'string') return null;
// Try to extract compressed-json block
const compressedMatch = md.match(/```\s*compressed-json\s*\n([\s\S]*?)\n```/i);
// Try to extract compressed-json block anywhere (Obsidian may wrap with %% comments or not)
const compressedMatch = md.match(/```\s*compressed-json\s*\r?\n([\s\S]*?)\r?\n```/i);
if (compressedMatch && compressedMatch[1]) {
try {
// Remove whitespace from base64 data
// Remove ALL whitespace (including newlines, spaces, tabs) from base64 data
const compressed = compressedMatch[1].replace(/\s+/g, '');
if (!compressed) {
console.warn('[Excalidraw] Empty compressed data');
return null;
}
// Decompress using LZ-String
const decompressed = LZString.decompressFromBase64(compressed);
@@ -50,19 +71,26 @@ export class ExcalidrawIoService {
return this.normalizeScene(data);
} catch (error) {
console.error('[Excalidraw] Failed to parse compressed-json:', error);
console.error('[Excalidraw] Error details:', error);
return null;
}
}
// Fallback: try to extract plain json block
const jsonMatch = md.match(/```\s*(?:excalidraw|json)\s*\n([\s\S]*?)\n```/i);
// Fallback: try to extract plain json fence anywhere
const jsonMatch = md.match(/```\s*json\s*\r?\n([\s\S]*?)\r?\n```/i);
if (jsonMatch && jsonMatch[1]) {
try {
const data = JSON.parse(jsonMatch[1].trim());
const jsonStr = jsonMatch[1].trim();
if (!jsonStr) {
console.warn('[Excalidraw] Empty JSON data');
return null;
}
const data = JSON.parse(jsonStr);
return this.normalizeScene(data);
} catch (error) {
console.error('[Excalidraw] Failed to parse json block:', error);
console.error('[Excalidraw] JSON string:', jsonMatch[1].substring(0, 100));
return null;
}
}
@@ -94,28 +122,35 @@ export class ExcalidrawIoService {
* Convert Excalidraw scene to Obsidian markdown format
*/
toObsidianMd(data: ExcalidrawScene, existingFrontMatter?: string | null): string {
// Normalize scene data
// Normalize scene data then sanitize and wrap into Excalidraw export payload
const scene = this.normalizeScene(data);
const sanitized = this.sanitizeForSave(scene);
const payload = {
type: 'excalidraw',
version: 2,
source: 'obsidian-excalidraw-plugin',
elements: sanitized.elements || [],
appState: sanitized.appState || {},
files: sanitized.files || {}
} as any;
// Serialize to JSON
const json = JSON.stringify(scene);
// Compress using LZ-String
// Serialize and compress
const json = JSON.stringify(payload);
const compressed = LZString.compressToBase64(json);
if (!compressed) {
throw new Error('[Excalidraw] Failed to compress data');
}
// Use existing front matter or create default
const frontMatter = existingFrontMatter?.trim() || `---
const frontMatter = existingFrontMatter || `---
excalidraw-plugin: parsed
tags: [excalidraw]
---`;
// Banner text (Obsidian standard)
const banner = `==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==
You can decompress Drawing data with the command palette: 'Decompress current Excalidraw file'. For more info check in plugin settings under 'Saving'`;
// Construct full markdown
// Construct Obsidian structure with only the compressed-json block (as used by the plugin)
return `${frontMatter}
${banner}
==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==
# Excalidraw Data
@@ -163,4 +198,4 @@ ${compressed}
if (!Array.isArray(data.elements)) return false;
return true;
}
}
}
@@ -44,6 +44,11 @@ import { VaultService } from '../../../services/vault.service';
class="mt-1 w-full text-left block text-sm px-2 py-1.5 rounded hover:bg-surface1 dark:hover:bg-card text-main dark:text-main hover:text-main dark:hover:text-gray-100">
API Tests Panel
</button>
<button
(click)="testsExcalidrawSelected.emit()"
class="mt-1 w-full text-left block text-sm px-2 py-1.5 rounded hover:bg-surface1 dark:hover:bg-card text-main dark:text-main hover:text-main dark:hover:text-gray-100">
Test Excalidraw
</button>
</div>
</section>
@@ -178,6 +183,7 @@ export class NimbusSidebarComponent implements OnChanges {
@Output() quickLinkSelected = new EventEmitter<string>();
@Output() markdownPlaygroundSelected = new EventEmitter<void>();
@Output() testsPanelSelected = new EventEmitter<void>();
@Output() testsExcalidrawSelected = new EventEmitter<void>();
@Output() helpPageSelected = new EventEmitter<void>();
@Output() aboutSelected = new EventEmitter<void>();
@@ -0,0 +1,287 @@
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit, ViewChild, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { DrawingsEditorComponent } from '../drawings/drawings-editor.component';
import { DrawingsFileService } from '../drawings/drawings-file.service';
import { ExcalidrawIoService, type ExcalidrawScene } from '../drawings/excalidraw-io.service';
import { VaultService } from '../../../services/vault.service';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-test-excalidraw-page',
standalone: true,
imports: [CommonModule, DrawingsEditorComponent],
template: `
<div class="h-full flex flex-col gap-3">
<div class="rounded-xl border border-border bg-card shadow-subtle">
<div class="flex items-center justify-between px-3 py-2">
<div class="flex items-center gap-2">
<h2 class="text-sm font-semibold">Test Excalidraw</h2>
<span class="text-[10px] font-semibold uppercase tracking-widest rounded-full bg-surface1 px-2 py-0.5 text-muted">Legacy Integration</span>
<ng-container *ngIf="currentPath(); else nofile">
<span class="ml-2 text-xs text-muted select-all truncate max-w-[32rem]" [title]="currentPath()">{{ currentPath() }}</span>
</ng-container>
<ng-template #nofile>
<span class="ml-2 text-xs text-muted">No file</span>
</ng-template>
<span class="ml-2 text-xs" [class.text-success]="!dirty()" [class.text-warning]="dirty()">• {{ dirty() ? 'Dirty' : 'Saved' }}</span>
</div>
<div class="flex items-center gap-1">
<button type="button" class="btn btn-ghost btn-sm" (click)="createNew()" title="Nouveau">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
<span class="ml-1 hidden sm:inline">Nouveau</span>
</button>
<div class="relative">
<button type="button" class="btn btn-ghost btn-sm" (click)="toggleOpenPicker()" title="Ouvrir">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5l2 2h9a2 2 0 0 1 2 2z"/></svg>
<span class="ml-1 hidden sm:inline">Ouvrir</span>
</button>
<div *ngIf="openPicker()" class="absolute right-0 mt-1 w-80 max-h-80 overflow-y-auto rounded-lg border border-border bg-card shadow-xl z-10">
<div class="p-2 text-xs text-muted">Sélectionner un fichier *.excalidraw.md</div>
<ul>
<li *ngFor="let f of excalidrawFiles()">
<button type="button" class="w-full text-left px-3 py-1.5 text-sm hover:bg-surface1" (click)="openFile(f.filePath)">{{ f.filePath }}</button>
</li>
</ul>
</div>
</div>
<button type="button" class="btn btn-ghost btn-sm" (click)="closeFile()" [disabled]="!currentPath()" title="Fermer le fichier">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
<span class="ml-1 hidden sm:inline">Fermer</span>
</button>
<div class="mx-1 h-5 w-px bg-border"></div>
<button type="button" class="btn btn-ghost btn-sm" (click)="saveDebounced()" [disabled]="!currentPath()" title="Enregistrer (Ctrl/Cmd+S)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h11l5 5v9a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
<span class="ml-1 hidden sm:inline">Enregistrer</span>
</button>
<button type="button" class="btn btn-ghost btn-sm" (click)="saveAs()" title="Enregistrer sous">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M19 12H5"/></svg>
<span class="ml-1 hidden sm:inline">Enregistrer sous</span>
</button>
<div class="mx-1 h-5 w-px bg-border"></div>
<div class="relative">
<button type="button" class="btn btn-ghost btn-sm" (click)="toggleExport()" title="Exporter">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7 11l5 5 5-5"/><path d="M5 19h14"/></svg>
<span class="ml-1 hidden sm:inline">Exporter</span>
</button>
<div *ngIf="openExport()" class="absolute right-0 mt-1 w-52 rounded-lg border border-border bg-card shadow-xl z-10">
<button class="w-full text-left px-3 py-2 text-sm hover:bg-surface1" (click)="exportPNG(true)">PNG (avec fond)</button>
<button class="w-full text-left px-3 py-2 text-sm hover:bg-surface1" (click)="exportPNG(false)">PNG (sans fond)</button>
<div class="h-px bg-border mx-2"></div>
<button class="w-full text-left px-3 py-2 text-sm hover:bg-surface1" (click)="exportSVG(true)">SVG (embed)</button>
<button class="w-full text-left px-3 py-2 text-sm hover:bg-surface1" (click)="exportSVG(false)">SVG</button>
<div class="h-px bg-border mx-2"></div>
<button class="w-full text-left px-3 py-2 text-sm hover:bg-surface1" (click)="exportJSON()">JSON (télécharger)</button>
</div>
</div>
<div class="mx-1 h-5 w-px bg-border"></div>
<label class="inline-flex items-center gap-2 text-xs text-muted select-none">
<input type="checkbox" [checked]="autosave()" (change)="setAutosave($any($event.target).checked)" /> Autosave 10s
</label>
<button type="button" class="btn btn-ghost btn-sm" (click)="toggleFullscreen()" title="Plein écran">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M16 3h3a2 2 0 0 1 2 2v3"/><path d="M8 21H5a2 2 0 0 1-2-2v-3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
</button>
</div>
</div>
</div>
<div class="flex-1 min-h-0">
<ng-container *ngIf="currentPath(); else noSelection">
<app-drawings-editor #editor [path]="currentPath()!"></app-drawings-editor>
</ng-container>
<ng-template #noSelection>
<div class="h-full flex items-center justify-center rounded-xl border border-border bg-card text-muted">
<div class="text-center space-y-2">
<div class="text-sm">Aucun fichier Excalidraw sélectionné</div>
<div class="flex items-center justify-center gap-2">
<button class="btn btn-sm" (click)="createNew()">Nouveau</button>
<button class="btn btn-sm btn-ghost" (click)="toggleOpenPicker()">Ouvrir…</button>
</div>
</div>
</div>
</ng-template>
</div>
</div>
`,
})
export class TestExcalidrawPageComponent implements OnInit, OnDestroy {
private readonly drawings = inject(DrawingsFileService);
private readonly excalIo = inject(ExcalidrawIoService);
private readonly vault = inject(VaultService);
private readonly router = inject(Router);
@ViewChild('editor', { static: false }) editor?: DrawingsEditorComponent;
currentPath = signal<string | null>(null);
openPicker = signal(false);
openExport = signal(false);
autosave = signal(false);
dirty = signal(false);
private autosaveTimer: any = null;
private saveTimer: any = null;
excalidrawFiles = signal<Array<{ filePath: string }>>([]);
async ngOnInit(): Promise<void> {
// lazy define custom element (in case editor needs it early)
try { await import('../../../../web-components/excalidraw/define'); } catch {}
// try to read ?file= param
try {
const sp = new URLSearchParams(window.location.search || '');
const file = sp.get('file');
if (file && /\.excalidraw(?:\.md)?$/i.test(file)) {
const clean = file.replace(/^\/+/, '');
this.currentPath.set(clean);
this.updateUrlFileParam(clean);
}
} catch {}
// build files list for picker
this.refreshFileList();
// poll editor dirty state via save hash comparison using existing editor polling
// here we just mirror the editor's internal dirty state by attempting a heuristic:
const syncDirty = () => {
// not accessible directly; rely on presence of '*' in title not available. Skip.
};
void syncDirty;
}
ngOnDestroy(): void {
if (this.autosaveTimer) clearInterval(this.autosaveTimer);
if (this.saveTimer) clearTimeout(this.saveTimer);
}
refreshFileList(): void {
const notes = this.vault.allNotes();
const list = notes
.filter(n => /\.excalidraw\.md$/i.test(n.filePath || ''))
.map(n => ({ filePath: n.filePath }));
this.excalidrawFiles.set(list);
}
toggleOpenPicker(): void {
this.openPicker.update(v => !v);
if (this.openPicker()) this.refreshFileList();
}
toggleExport(): void {
this.openExport.update(v => !v);
}
async createNew(): Promise<void> {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const base = `Drawing-${y}${m}${d}-${hh}${mm}`;
const name = prompt('Nom du fichier', `${base}.excalidraw.md`);
if (!name) return;
const file = name.endsWith('.excalidraw.md') ? name : `${name}.excalidraw.md`;
const path = file.replace(/^\/+/, '');
const scene: ExcalidrawScene = { elements: [], appState: {}, files: {} };
const fm = `---\nexcalidraw-plugin: parsed\ncreated: "${now.toISOString()}"\nupdated: "${now.toISOString()}"\ntitle: "${(file || '').replace(/\.excalidraw\.md$/i,'')}"\n---`;
const md = this.excalIo.toObsidianMd(scene, fm);
await firstValueFrom(this.drawings.putText(path, md));
this.currentPath.set(path);
this.updateUrlFileParam(path);
this.openPicker.set(false);
// focus editor next tick
setTimeout(() => this.editor?.onExcalidrawReady?.(), 0);
}
openFile(path: string): void {
this.currentPath.set(path);
this.updateUrlFileParam(path);
this.openPicker.set(false);
}
async saveDebounced(): Promise<void> {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.save(), 1200);
}
async save(): Promise<void> {
await this.editor?.saveNow();
}
async saveAs(): Promise<void> {
const current = this.currentPath() || '';
const suggested = current ? current.split('/').pop() || 'Drawing.excalidraw.md' : 'Drawing.excalidraw.md';
const name = prompt('Enregistrer sous (racine de la voûte)', suggested);
if (!name) return;
const file = name.endsWith('.excalidraw.md') ? name : `${name}.excalidraw.md`;
const path = file.replace(/^\/+/, '');
// Get latest scene from server after manual save to ensure consistency
await this.editor?.saveNow();
const scene = await firstValueFrom(this.drawings.get(this.currentPath() || ''));
const fm = `---\nexcalidraw-plugin: parsed\ncreated: "${new Date().toISOString()}"\nupdated: "${new Date().toISOString()}"\ntitle: "${(file || '').replace(/\.excalidraw\.md$/i,'')}"\n---`;
const md = this.excalIo.toObsidianMd(scene, fm);
await firstValueFrom(this.drawings.putText(path, md));
this.currentPath.set(path);
this.updateUrlFileParam(path);
}
async exportPNG(withBg: boolean): Promise<void> {
await this.editor?.exportPNG(withBg);
this.openExport.set(false);
}
async exportSVG(withBg: boolean): Promise<void> {
await this.editor?.exportSVG(withBg);
this.openExport.set(false);
}
private updateUrlFileParam(filePath: string | null): void {
try {
this.router.navigate([], { queryParams: { file: filePath || null }, queryParamsHandling: 'merge', preserveFragment: true });
} catch {}
}
closeFile(): void {
this.currentPath.set(null);
this.openPicker.set(false);
this.openExport.set(false);
this.updateUrlFileParam(null);
}
async exportJSON(): Promise<void> {
const path = this.currentPath();
if (!path) return;
try {
const scene = await firstValueFrom(this.drawings.get(path));
const blob = new Blob([JSON.stringify(scene, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = path.replace(/\.excalidraw(?:\.md)?$/i, '.json');
a.click();
URL.revokeObjectURL(url);
} catch (e) {
console.error('Export JSON failed', e);
}
}
setAutosave(on: boolean): void {
this.autosave.set(on);
if (this.autosaveTimer) { clearInterval(this.autosaveTimer); this.autosaveTimer = null; }
if (on) {
this.autosaveTimer = setInterval(() => {
if (this.currentPath()) {
this.editor?.saveNow();
}
}, 10000);
}
}
toggleFullscreen(): void {
this.editor?.toggleFullscreen();
}
}
@@ -17,6 +17,7 @@ import { QuickLinksComponent } from '../../features/quick-links/quick-links.comp
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playground/markdown-playground.component';
import { TestsPanelComponent } from '../../features/tests/tests-panel.component';
import { TestExcalidrawPageComponent } from '../../features/tests/test-excalidraw-page.component';
import { ParametersPage } from '../../features/parameters/parameters.page';
import { AboutPanelComponent } from '../../features/about/about-panel.component';
import { UrlStateService } from '../../services/url-state.service';
@@ -28,12 +29,12 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
@Component({
selector: 'app-shell-nimbus-layout',
standalone: true,
imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, NotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent, TestsPanelComponent, ParametersPage, AboutPanelComponent, NoteInfoModalComponent, InPageSearchOverlayComponent],
imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, NotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent, TestsPanelComponent, TestExcalidrawPageComponent, ParametersPage, AboutPanelComponent, NoteInfoModalComponent, InPageSearchOverlayComponent],
template: `
<div class="relative h-screen flex flex-col bg-card dark:bg-main text-main dark:text-gray-100" [style.--sidebar-width.px]="isSidebarOpen ? leftSidebarWidth : 64">
<!-- Fullscreen overlay for note -->
<div *ngIf="noteFullScreen && selectedNote && activeView !== 'markdown-playground'" class="absolute inset-0 z-50 flex flex-col bg-card dark:bg-main">
<div *ngIf="noteFullScreen && selectedNote && activeView !== 'markdown-playground' && activeView !== 'tests-excalidraw'" class="absolute inset-0 z-50 flex flex-col bg-card dark:bg-main">
<div class="note-content-area flex-1 overflow-y-auto px-4 py-4 lg:px-12" appScrollableOverlay>
<app-note-viewer
[note]="selectedNote || null"
@@ -74,6 +75,7 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
(quickLinkSelected)="onQuickLink($event)"
(markdownPlaygroundSelected)="onMarkdownPlaygroundSelected()"
(testsPanelSelected)="onTestsPanelSelected()"
(testsExcalidrawSelected)="onTestsExcalidrawSelected()"
(helpPageSelected)="onHelpPageSelected()"
(aboutSelected)="onAboutSelected()"
(noteCreated)="onNoteCreated($event)"
@@ -146,6 +148,7 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
<div *ngSwitchCase="'tests'" class="p-3">
<button type="button" class="w-full text-left flex items-center gap-2 px-3 py-2 text-sm rounded-lg hover:bg-surface1 dark:hover:bg-card" (click)="onTestsPanelSelected(); $event.stopPropagation(); hoveredFlyout = null">🔬 <span>API Tests Panel</span></button>
<button type="button" class="w-full text-left flex items-center gap-2 px-3 py-2 text-sm rounded-lg hover:bg-surface1 dark:hover:bg-card" (click)="onMarkdownPlaygroundSelected(); $event.stopPropagation(); hoveredFlyout = null">📝 <span>Markdown Playground</span></button>
<button type="button" class="w-full text-left flex items-center gap-2 px-3 py-2 text-sm rounded-lg hover:bg-surface1 dark:hover:bg-card" (click)="onTestsExcalidrawSelected(); $event.stopPropagation(); hoveredFlyout = null">🎨 <span>Test Excalidraw</span></button>
</div>
<div *ngSwitchCase="'playground'" class="p-3">
<button type="button" class="w-full text-left flex items-center gap-2 px-3 py-2 text-sm rounded-lg hover:bg-surface1 dark:hover:bg-card" (click)="onMarkdownPlaygroundSelected(); $event.stopPropagation(); hoveredFlyout = null">🧪 <span>Markdown Playground</span></button>
@@ -187,7 +190,8 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
<app-markdown-playground *ngIf="activeView === 'markdown-playground'"></app-markdown-playground>
<app-parameters *ngIf="activeView === 'parameters'"></app-parameters>
<app-tests-panel *ngIf="activeView === 'tests-panel'"></app-tests-panel>
<app-note-viewer *ngIf="activeView !== 'markdown-playground' && activeView !== 'parameters' && activeView !== 'tests-panel'"
<app-test-excalidraw-page *ngIf="activeView === 'tests-excalidraw'"></app-test-excalidraw-page>
<app-note-viewer *ngIf="activeView !== 'markdown-playground' && activeView !== 'parameters' && activeView !== 'tests-panel' && activeView !== 'tests-excalidraw'"
[note]="selectedNote || null"
[noteHtmlContent]="renderedNoteContent"
[allNotes]="vault.allNotes()"
@@ -255,7 +259,8 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
<app-markdown-playground *ngIf="activeView === 'markdown-playground'"></app-markdown-playground>
<app-parameters *ngIf="activeView === 'parameters'"></app-parameters>
<app-tests-panel *ngIf="activeView === 'tests-panel'"></app-tests-panel>
<app-note-viewer *ngIf="activeView !== 'markdown-playground' && activeView !== 'parameters' && activeView !== 'tests-panel'" [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="onTagSelected($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)" [fullScreenActive]="noteFullScreen" (fullScreenRequested)="toggleNoteFullScreen()" (legacyRequested)="ui.toggleUIMode()" (parametersRequested)="onParametersOpen()" (showToc)="mobileNav.toggleToc()" (directoryClicked)="onFolderSelected($event)" [tocOpen]="mobileNav.tocOpen()"></app-note-viewer>
<app-test-excalidraw-page *ngIf="activeView === 'tests-excalidraw'"></app-test-excalidraw-page>
<app-note-viewer *ngIf="activeView !== 'markdown-playground' && activeView !== 'parameters' && activeView !== 'tests-panel' && activeView !== 'tests-excalidraw'" [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="onTagSelected($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)" [fullScreenActive]="noteFullScreen" (fullScreenRequested)="toggleNoteFullScreen()" (legacyRequested)="ui.toggleUIMode()" (parametersRequested)="onParametersOpen()" (showToc)="mobileNav.toggleToc()" (directoryClicked)="onFolderSelected($event)" [tocOpen]="mobileNav.tocOpen()"></app-note-viewer>
</div>
</div>
</div>
@@ -297,6 +302,10 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
<div class="note-content-area h-full px-3 py-3 animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
<app-tests-panel></app-tests-panel>
</div>
} @else if (activeView === 'tests-excalidraw') {
<div class="note-content-area h-full px-3 py-3 animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
<app-test-excalidraw-page></app-test-excalidraw-page>
</div>
} @else {
<div class="note-content-area h-full px-3 py-3 animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
@if (selectedNote) {
@@ -370,6 +379,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
@Output() parametersOpened = new EventEmitter<void>();
@Output() helpPageRequested = new EventEmitter<void>();
@Output() testsPanelRequested = new EventEmitter<void>();
@Output() testsExcalidrawRequested = new EventEmitter<void>();
folderFilter: string | null = null;
listQuery: string = '';
@@ -857,6 +867,10 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
this.testsPanelRequested.emit();
}
onTestsExcalidrawSelected(): void {
this.testsExcalidrawRequested.emit();
}
onParametersOpen(): void {
this.parametersOpened.emit();
}