feat: enhance Excalidraw editor UI and autosave functionality

- Redesigned editor toolbar with improved layout and accessibility attributes
- Added automatic save after 7 seconds of inactivity for better data protection
- Replaced emoji icons with SVG icons for consistent styling and accessibility
- Added success toasts for PNG/SVG exports with destination path
- Updated button styles and layout for better visual hierarchy
- Added aria-labels and improved keyboard navigation support
- Made action buttons optionally hideable via
This commit is contained in:
2025-10-28 22:25:40 -04:00
parent 5647b42d8a
commit b1f142c4f7
17 changed files with 335 additions and 284 deletions
@@ -3,25 +3,27 @@
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<!-- Indicateur de sauvegarde (cliquable) -->
<button
type="button"
class="flex items-center gap-2 px-3 py-2 rounded-md transition-colors"
<button
*ngIf="showInlineActions"
type="button"
class="btn btn-sm btn-ghost flex items-center gap-2"
[class.cursor-pointer]="!isLoading() && !isSaving()"
[class.cursor-wait]="isSaving()"
[class.cursor-not-allowed]="isLoading()"
(click)="saveNow()"
[disabled]="isLoading()"
[attr.aria-label]="dirty() ? 'Sauver maintenant' : 'Déjà sauvegardé'"
title="{{dirty() ? 'Non sauvegardé - Cliquer pour sauvegarder (Ctrl+S)' : isSaving() ? 'Sauvegarde en cours...' : 'Sauvegardé'}}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
[class.text-red-500]="dirty() && !isSaving()"
[class.text-muted]="!dirty() && !isSaving()"
@@ -32,33 +34,51 @@
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
<span class="text-xs font-medium" [class.text-red-500]="dirty() && !isSaving()" [class.text-muted]="!dirty() && !isSaving()" [class.text-yellow-500]="isSaving()">
<span *ngIf="showInlineActions" class="text-xs font-medium" [class.text-red-500]="dirty() && !isSaving()" [class.text-muted]="!dirty() && !isSaving()" [class.text-yellow-500]="isSaving()">
{{isSaving() ? 'Sauvegarde...' : dirty() ? 'Non sauvegardé' : 'Sauvegardé'}}
</span>
</button>
<button
type="button"
class="btn btn-sm"
<button
*ngIf="showInlineActions"
type="button"
class="btn btn-sm flex items-center gap-2"
(click)="exportPNG()"
[disabled]="isLoading() || isSaving() || !excalidrawReady"
aria-label="Exporter en PNG"
>
🖼️ Export PNG
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="14" rx="2" ry="2"></rect>
<circle cx="8" cy="8" r="2"></circle>
<path d="M21 17l-5-6-4 5-2-3-5 6"></path>
</svg>
Export PNG
</button>
<button
type="button"
class="btn btn-sm"
<button
*ngIf="showInlineActions"
type="button"
class="btn btn-sm flex items-center gap-2"
(click)="exportSVG()"
[disabled]="isLoading() || isSaving() || !excalidrawReady"
aria-label="Exporter en SVG"
>
🧩 Export SVG
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Export SVG
</button>
<button
type="button"
class="btn btn-sm"
<button
*ngIf="showInlineActions"
type="button"
class="btn btn-sm flex items-center gap-2"
(click)="toggleFullscreen()"
[disabled]="isLoading()"
[title]="isFullscreen() ? 'Quitter le mode pleine écran' : 'Passer en mode pleine écran'"
aria-label="Basculer le mode plein écran"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -76,7 +96,7 @@
<polyline points="10,17 15,12 10,7"></polyline>
<line x1="15" x2="3" y1="12" y2="12"></line>
</svg>
{{isFullscreen() ? 'Quitter FS' : 'Pleine écran'}}
{{isFullscreen() ? 'Quitter FS' : 'Plein écran'}}
</button>
</div>
@@ -16,7 +16,7 @@ import {
signal
} from '@angular/core';
import { firstValueFrom, fromEvent, of, Subscription, interval } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, switchMap, tap, catchError, filter } from 'rxjs/operators';
import { debounceTime, distinctUntilChanged, map, switchMap, tap, catchError, filter, mergeWith } from 'rxjs/operators';
import { DrawingsFileService, ExcalidrawScene } from './drawings-file.service';
import { ExcalidrawIoService } from './excalidraw-io.service';
import { DrawingsPreviewService } from './drawings-preview.service';
@@ -32,6 +32,7 @@ import { ThemeService } from '../../core/services/theme.service';
})
export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy {
@Input() path: string = '';
@Input() showInlineActions: boolean = true;
@ViewChild('editorEl', { static: false }) editorEl?: ElementRef<HTMLElement & {
setScene: (scene: any) => void;
@@ -628,42 +629,76 @@ export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy
}
});
// Subscription 2: Handle auto-saving (disabled temporarily)
// this.saveSub = sceneChange$.pipe(
// distinctUntilChanged((prev, curr) => prev.hash === curr.hash),
// debounceTime(2000),
// filter(({ hash }) => this.lastSavedHash !== hash),
// filter(() => !this.isSaving()),
// tap(({ hash }) => {
// console.log('💾 Autosaving... hash:', hash.substring(0, 10));
// this.isSaving.set(true);
// this.error.set(null);
// }),
// switchMap(({ scene, hash }) =>
// this.files.put(this.path, scene).pipe(
// tap(async () => {
// console.log('✅ Autosave successful', { newHash: hash.substring(0, 10) });
// this.lastSavedHash = hash;
// this.dirty.set(false);
// this.isSaving.set(false);
// this.hasConflict.set(false);
// try { await this.savePreviewPNG(); } catch (e) { console.warn('PNG preview update failed', e); }
// }),
// catchError((e) => {
// console.error('❌ Save error:', e);
// const status = e?.status ?? e?.statusCode;
// if (status === 409) {
// this.error.set('Conflit: le fichier a été modifié sur le disque.');
// this.hasConflict.set(true);
// } else {
// this.error.set('Erreur de sauvegarde. Veuillez réessayer.');
// }
// this.isSaving.set(false);
// return of({ rev: '' } as any);
// })
// )
// )
// ).subscribe();
// Subscription 2: Natural autosave after inactivity (idle-based)
const triggers$ = fromEvent(host, 'pointermove').pipe(
mergeWith(
fromEvent(document, 'keydown'),
fromEvent(host, 'wheel'),
sceneChange$.pipe(map(() => null))
),
debounceTime(7000),
filter(() => this.dirty() && !this.isSaving())
);
this.saveSub = triggers$.pipe(
switchMap(() => {
const scene = this.scene();
if (!scene) return of(null);
const hash = this.hashScene(scene);
if (this.lastSavedHash === hash) return of(null);
this.isSaving.set(true);
this.error.set(null);
const isExMd = /\.excalidraw\.md$/i.test(this.path || '');
const save$ = isExMd
? this.files.getText(this.path).pipe(
catchError(() => of('')),
switchMap((existing) => {
let fm = this.excalIo.extractFrontMatter(existing) || null;
if (fm) {
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`);
}
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);
return this.files.putText(this.path, md);
})
)
: this.files.put(this.path, scene);
return save$.pipe(
tap(async () => {
this.lastSavedHash = hash;
this.dirty.set(false);
this.isSaving.set(false);
this.hasConflict.set(false);
try { await this.savePreviewPNG(); } catch (e) { console.warn('PNG preview update failed', e); }
}),
catchError((e) => {
console.error('❌ Autosave error:', e);
const status = (e as any)?.status ?? (e as any)?.statusCode;
if (status === 409) {
this.error.set('Conflit: le fichier a été modifié sur le disque.');
this.hasConflict.set(true);
} else {
this.error.set('Erreur de sauvegarde automatique.');
}
this.isSaving.set(false);
return of(null);
})
);
})
).subscribe();
console.log('✓ Dirty check and Autosave subscriptions active');
@@ -725,6 +760,11 @@ export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy
const target = `${base}.png`;
await firstValueFrom(this.files.putBinary(target, blob, 'image/png'));
this.error.set(null);
try {
const norm = target.replace(/\\/g, '/');
const dir = norm.includes('/') ? norm.substring(0, norm.lastIndexOf('/')) : '/';
this.showToast(`PNG exporté avec succès dans ${dir}`, 'success');
} catch {}
} catch (err) {
console.error('Error exporting PNG:', err);
this.error.set('Erreur lors de l\'export en PNG');
@@ -752,6 +792,11 @@ export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy
const target = `${base}.svg`;
await firstValueFrom(this.files.putBinary(target, blob, 'image/svg+xml'));
this.error.set(null);
try {
const norm = target.replace(/\\/g, '/');
const dir = norm.includes('/') ? norm.substring(0, norm.lastIndexOf('/')) : '/';
this.showToast(`SVG exporté avec succès dans ${dir}`, 'success');
} catch {}
} catch (err) {
console.error('Error exporting SVG:', err);
this.error.set('Erreur lors de l\'export en SVG');
@@ -45,6 +45,16 @@ import { TrashExplorerComponent } from '../../layout/sidebar/trash/trash-explore
class="w-full text-left block text-sm px-3 py-2 rounded-lg hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 text-main dark:text-main hover:text-main dark:hover:text-gray-100 transition-all active:scale-[0.98] transform">
🧪 Markdown Playground
</button>
<button
(click)="onApiTestsPanelClick()"
class="mt-1 w-full text-left block text-sm px-3 py-2 rounded-lg hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 text-main dark:text-main hover:text-main dark:hover:text-gray-100 transition-all active:scale-[0.98] transform">
🔬 API Tests Panel
</button>
<button
(click)="onTestsExcalidrawClick()"
class="mt-1 w-full text-left block text-sm px-3 py-2 rounded-lg hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 text-main dark:text-main hover:text-main dark:hover:text-gray-100 transition-all active:scale-[0.98] transform">
🎨 Test Excalidraw
</button>
</div>
</section>
@@ -158,6 +168,8 @@ export class AppSidebarDrawerComponent {
@Output() tagSelected = new EventEmitter<string>();
@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>();
@@ -189,6 +201,16 @@ export class AppSidebarDrawerComponent {
this.mobileNav.sidebarOpen.set(false);
}
onApiTestsPanelClick(): void {
this.testsPanelSelected.emit();
this.mobileNav.sidebarOpen.set(false);
}
onTestsExcalidrawClick(): void {
this.testsExcalidrawSelected.emit();
this.mobileNav.sidebarOpen.set(false);
}
onHelpPageClick(): void {
this.helpPageSelected.emit();
this.mobileNav.sidebarOpen.set(false);
@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit, ViewChild, inject, signal } from '@angular/core';
import { AfterViewInit, 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';
@@ -13,58 +13,75 @@ import { firstValueFrom } from 'rxjs';
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 class="sticky top-0 z-40">
<div class="rounded-2xl border border-border/60 bg-card/80 backdrop-blur-[2px] shadow-subtle max-w-full overflow-visible">
<div class="flex flex-wrap items-center gap-2 px-3 pt-2">
<div class="flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
<!-- <h2 class="text-sm font-semibold">Test Excalidraw</h2> -->
<ng-container *ngIf="currentPath(); else nofile">
<span class="ml-2 text-xs text-muted truncate select-text" [title]="currentPath()">{{ currentPath() }}</span>
</ng-container>
<ng-template #nofile>
<span class="ml-2 text-xs text-muted">Aucun fichier</span>
</ng-template>
<div class="ml-auto flex items-center">
<span
class="inline-flex h-7 w-7 items-center justify-center rounded-full border border-border/60 bg-card/70 transition-colors"
[class.text-emerald-400]="!dirty()"
[class.text-red-500]="dirty()"
[title]="dirty() ? 'Dessin non sauvegardé' : 'Dessin sauvegardé'"
aria-label="Statut de sauvegarde"
>
<svg width="14" height="14" 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-2V5a2 2 0 0 1 2-2h11l5 5v11a2 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="sr-only">{{ dirty() ? 'Non sauvegardé' : 'Sauvegardé' }}</span>
</span>
</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>
</div>
<!-- Row 1: Primary actions -->
<div class="flex items-center justify-center gap-1 md:gap-2 px-3">
<div class="flex items-center gap-1">
<button type="button" class="btn btn-ghost btn-sm" (click)="createNew()" title="Nouveau" aria-label="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 md:inline">Nouveau</span>
</button>
<div *ngIf="openExport()" class="absolute right-0 mt-1 w-52 rounded-lg border border-border bg-card shadow-xl z-10">
<div class="relative">
<button type="button" class="btn btn-ghost btn-sm" (click)="toggleOpenPicker()" title="Ouvrir" aria-haspopup="menu" aria-expanded="{{openPicker()}}">
<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 md:inline">Ouvrir</span>
</button>
<div *ngIf="openPicker()" class="absolute right-0 mt-1 w-72 max-w-[80vw] max-h-80 overflow-y-auto rounded-xl border border-border bg-card shadow-2xl z-50">
<div class="px-3 py-2 text-xs text-muted">Sélectionner un fichier *.excalidraw.md</div>
<ul class="divide-y divide-border/40">
<li *ngFor="let f of excalidrawFiles()">
<button type="button" class="w-full text-left px-3 py-2 text-sm hover:bg-surface1 truncate" (click)="openFile(f.filePath)" [title]="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" aria-label="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 md:inline">Fermer</span>
</button>
<button type="button" class="btn btn-ghost btn-sm" (click)="saveDebounced()" [disabled]="!currentPath()" title="Enregistrer (Ctrl/Cmd+S)" aria-label="Enregistrer">
<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 md:inline">Enregistrer</span>
</button>
</div>
</div>
<div class="h-px bg-border/70 mx-2 my-1"></div>
<!-- Row 2: Export only -->
<div class="flex items-center justify-center gap-1 md:gap-2 px-3 pb-2">
<div class="relative">
<button type="button" class="btn btn-ghost btn-sm" (click)="toggleExport()" title="Exporter" aria-haspopup="menu" aria-expanded="{{openExport()}}">
<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">Exporter</span>
</button>
<div *ngIf="openExport()" class="absolute right-0 mt-1 w-64 max-w-[75vw] rounded-xl border border-border bg-card shadow-2xl z-50">
<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>
@@ -74,20 +91,13 @@ import { firstValueFrom } from 'rxjs';
<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>
<app-drawings-editor #editor [path]="currentPath()!" [showInlineActions]="false"></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">
@@ -104,7 +114,7 @@ import { firstValueFrom } from 'rxjs';
</div>
`,
})
export class TestExcalidrawPageComponent implements OnInit, OnDestroy {
export class TestExcalidrawPageComponent implements OnInit, OnDestroy, AfterViewInit {
private readonly drawings = inject(DrawingsFileService);
private readonly excalIo = inject(ExcalidrawIoService);
private readonly vault = inject(VaultService);
@@ -120,6 +130,7 @@ export class TestExcalidrawPageComponent implements OnInit, OnDestroy {
private autosaveTimer: any = null;
private saveTimer: any = null;
private dirtySyncInterval: any = null;
excalidrawFiles = signal<Array<{ filePath: string }>>([]);
@@ -141,17 +152,20 @@ export class TestExcalidrawPageComponent implements OnInit, OnDestroy {
// 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;
this.startDirtySync();
}
ngAfterViewInit(): void {
this.startDirtySync();
}
ngOnDestroy(): void {
if (this.autosaveTimer) clearInterval(this.autosaveTimer);
if (this.saveTimer) clearTimeout(this.saveTimer);
if (this.dirtySyncInterval) {
clearInterval(this.dirtySyncInterval);
this.dirtySyncInterval = null;
}
}
refreshFileList(): void {
@@ -191,6 +205,7 @@ export class TestExcalidrawPageComponent implements OnInit, OnDestroy {
this.currentPath.set(path);
this.updateUrlFileParam(path);
this.openPicker.set(false);
this.dirty.set(false);
// focus editor next tick
setTimeout(() => this.editor?.onExcalidrawReady?.(), 0);
}
@@ -199,6 +214,7 @@ export class TestExcalidrawPageComponent implements OnInit, OnDestroy {
this.currentPath.set(path);
this.updateUrlFileParam(path);
this.openPicker.set(false);
this.dirty.set(false);
}
async saveDebounced(): Promise<void> {
@@ -208,6 +224,17 @@ export class TestExcalidrawPageComponent implements OnInit, OnDestroy {
async save(): Promise<void> {
await this.editor?.saveNow();
this.dirty.set(this.editor?.dirty?.() ?? false);
}
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);
}
async saveAs(): Promise<void> {
@@ -228,16 +255,6 @@ export class TestExcalidrawPageComponent implements OnInit, OnDestroy {
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 });
@@ -247,8 +264,8 @@ export class TestExcalidrawPageComponent implements OnInit, OnDestroy {
closeFile(): void {
this.currentPath.set(null);
this.openPicker.set(false);
this.openExport.set(false);
this.updateUrlFileParam(null);
this.dirty.set(false);
}
async exportJSON(): Promise<void> {
@@ -275,6 +292,7 @@ export class TestExcalidrawPageComponent implements OnInit, OnDestroy {
this.autosaveTimer = setInterval(() => {
if (this.currentPath()) {
this.editor?.saveNow();
this.dirty.set(this.editor?.dirty?.() ?? false);
}
}, 10000);
}
@@ -284,4 +302,14 @@ export class TestExcalidrawPageComponent implements OnInit, OnDestroy {
this.editor?.toggleFullscreen();
}
private startDirtySync(): void {
if (this.dirtySyncInterval) return;
this.dirtySyncInterval = setInterval(() => {
const childDirty = this.editor?.dirty?.();
if (typeof childDirty === 'boolean' && childDirty !== this.dirty()) {
this.dirty.set(childDirty);
}
}, 800);
}
}
@@ -279,6 +279,7 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
(quickLinkSelected)="onQuickLink($event)"
(markdownPlaygroundSelected)="onMarkdownPlaygroundSelected()"
(testsPanelSelected)="onTestsPanelSelected()"
(testsExcalidrawSelected)="onTestsExcalidrawSelected()"
(helpPageSelected)="onHelpPageSelected()"
(aboutSelected)="onAboutSelected()"
></app-sidebar-drawer>
@@ -860,14 +861,23 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
}
onMarkdownPlaygroundSelected(): void {
if (this.responsive.isMobile()) {
this.mobileNav.setActiveTab('page');
}
this.markdownPlaygroundSelected.emit();
}
onTestsPanelSelected(): void {
if (this.responsive.isMobile()) {
this.mobileNav.setActiveTab('page');
}
this.testsPanelRequested.emit();
}
onTestsExcalidrawSelected(): void {
if (this.responsive.isMobile()) {
this.mobileNav.setActiveTab('page');
}
this.testsExcalidrawRequested.emit();
}