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