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:
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user