feat: add Meilisearch backend integration with Docker Compose setup and Excalidraw support

This commit is contained in:
2025-10-14 10:41:15 -04:00
parent b359e8ab8e
commit 32a9998b40
71 changed files with 11544 additions and 435 deletions
@@ -0,0 +1,143 @@
<div class="flex flex-col h-[calc(100vh-180px)] lg:h-[calc(100vh-140px)] gap-2">
<!-- En-tête avec les boutons et les états -->
<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"
[class.cursor-pointer]="!isLoading() && !isSaving()"
[class.cursor-wait]="isSaving()"
[class.cursor-not-allowed]="isLoading()"
(click)="saveNow()"
[disabled]="isLoading()"
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"
stroke-linejoin="round"
[class.text-red-500]="dirty() && !isSaving()"
[class.text-gray-400]="!dirty() && !isSaving()"
[class.text-yellow-500]="isSaving()"
[class.animate-pulse]="isSaving()"
>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<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-gray-400]="!dirty() && !isSaving()" [class.text-yellow-500]="isSaving()">
{{isSaving() ? 'Sauvegarde...' : dirty() ? 'Non sauvegardé' : 'Sauvegardé'}}
</span>
</button>
<button
type="button"
class="btn btn-sm"
(click)="exportPNG()"
[disabled]="isLoading() || isSaving() || !excalidrawReady"
>
🖼️ Export PNG
</button>
<button
type="button"
class="btn btn-sm"
(click)="exportSVG()"
[disabled]="isLoading() || isSaving() || !excalidrawReady"
>
🧩 Export SVG
</button>
<button
type="button"
class="btn btn-sm"
(click)="toggleFullscreen()"
[disabled]="isLoading()"
[title]="isFullscreen() ? 'Quitter le mode pleine écran' : 'Passer en mode pleine écran'"
>
<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-blue-500]="isFullscreen()"
>
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
<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'}}
</button>
</div>
<!-- Conflit: fichier modifié sur le disque -->
<div *ngIf="hasConflict()" class="rounded-md border border-amber-600 bg-amber-900/30 text-amber-200 px-3 py-2">
<div class="flex items-center justify-between gap-3">
<div>
<strong>Conflit détecté</strong> — Le fichier a été modifié sur le disque. Choisissez une action.
</div>
<div class="flex items-center gap-2">
<button class="btn btn-xs" (click)="reloadFromDisk()">Recharger depuis le disque</button>
<button class="btn btn-xs btn-danger" (click)="resolveConflictOverwrite()">Écraser par la version locale</button>
</div>
</div>
</div>
<!-- Affichage des erreurs seulement -->
<div class="flex items-center gap-4" *ngIf="error() as err">
<div class="text-xs text-red-600">
{{ err }}
</div>
</div>
<!-- Fin de l'en-tête -->
</div>
<!-- État de chargement -->
<div *ngIf="isLoading()" class="flex-1 flex items-center justify-center">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-2"></div>
<p class="text-text-muted">Chargement du dessin...</p>
</div>
</div>
<!-- Éditeur Excalidraw -->
<div
*ngIf="!isLoading()"
class="flex-1 min-h-0 rounded-xl border border-border bg-card overflow-hidden relative excalidraw-host"
[class.opacity-50]="isSaving()"
>
<excalidraw-editor
#editorEl
[initialData]="scene() || { elements: [], appState: { viewBackgroundColor: '#1e1e1e' } }"
[theme]="themeName()"
[lang]="'fr'"
(ready)="console.log('READY', $event); onExcalidrawReady()"
style="display:block; height:100%; width:100%"
></excalidraw-editor>
</div>
<!-- Toast notifications -->
<div *ngIf="toastMessage() as msg" class="fixed bottom-4 right-4 z-50">
<div
class="px-3 py-2 rounded-md shadow-md text-sm"
[ngClass]="{
'bg-green-600 text-white': toastType() === 'success',
'bg-red-600 text-white': toastType() === 'error',
'bg-gray-700 text-white': toastType() === 'info'
}"
>
{{ msg }}
</div>
</div>
</div>
@@ -0,0 +1,792 @@
import { CommonModule } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
ElementRef,
HostListener,
Input,
OnDestroy,
OnInit,
ViewChild,
computed,
inject,
signal
} from '@angular/core';
import { firstValueFrom, fromEvent, of, Subscription, interval } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, switchMap, tap, catchError, filter } from 'rxjs/operators';
import { DrawingsFileService, ExcalidrawScene } from './drawings-file.service';
import { ExcalidrawIoService } from './excalidraw-io.service';
import { DrawingsPreviewService } from './drawings-preview.service';
import { ThemeService } from '../../core/services/theme.service';
@Component({
selector: 'app-drawings-editor',
standalone: true,
imports: [CommonModule],
templateUrl: './drawings-editor.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy {
@Input() path: string = '';
@ViewChild('editorEl', { static: false }) editorEl?: ElementRef<HTMLElement & {
setScene: (scene: any) => void;
__excalidrawAPI: any;
}>;
private readonly files = inject(DrawingsFileService);
private readonly excalIo = inject(ExcalidrawIoService);
private readonly previews = inject(DrawingsPreviewService);
private readonly theme = inject(ThemeService);
private readonly destroyRef = inject(DestroyRef);
// Propriétés réactives accessibles depuis le template
scene = signal<ExcalidrawScene | null>(null);
error = signal<string | null>(null);
isLoading = signal<boolean>(true);
isSaving = signal<boolean>(false);
dirty = signal<boolean>(false);
// Toast and conflict state
toastMessage = signal<string | null>(null);
toastType = signal<'success' | 'error' | 'info'>('info');
hasConflict = signal<boolean>(false);
isFullscreen = signal<boolean>(false);
private saveSub: Subscription | null = null;
private dirtyCheckSub: Subscription | null = null;
private sceneUpdateSub: Subscription | null = null;
private lastSavedHash: string | null = null;
private pollSub: Subscription | null = null;
excalidrawReady = false;
private hostListenersBound = false;
private latestSceneFromEvents: ExcalidrawScene | null = null;
readonly themeName = computed<'light' | 'dark'>(() => (this.theme.isDark() ? 'dark' : 'light'));
async ngOnInit(): Promise<void> {
try {
// Lazy register the custom element
await import('../../../../web-components/excalidraw/define');
this.setupExcalidraw();
} catch (err) {
console.error('Failed to load Excalidraw:', err);
this.error.set('Erreur de chargement de l\'éditeur de dessin.');
}
}
private waitForNextSceneChange(host: any, timeoutMs = 300): Promise<ExcalidrawScene | null> {
return new Promise((resolve) => {
let done = false;
const handler = (e: any) => {
if (done) return;
done = true;
try {
const detail = e?.detail as Partial<ExcalidrawScene> | undefined;
const normalized = detail ? this.normalizeSceneDetail(detail) : null;
resolve(normalized);
} catch {
resolve(null);
}
};
try { host.addEventListener('scene-change', handler, { once: true }); } catch {}
setTimeout(() => {
if (done) return;
done = true;
try { host.removeEventListener('scene-change', handler as any); } catch {}
resolve(null);
}, timeoutMs);
});
}
// Export current canvas as PNG and save alongside the drawing file
private async savePreviewPNG(): Promise<void> {
const host = this.editorEl?.nativeElement;
if (!host || !this.excalidrawReady) return;
try {
const blob = await this.previews.exportPNGFromElement(host, true);
if (!blob) return;
const base = String(this.path || '').replace(/\.excalidraw(?:\.md)?$/i, '');
const target = `${base}.png`;
await firstValueFrom(this.files.putBinary(target, blob, 'image/png'));
} catch (e) {
// Non bloquant
console.warn('savePreviewPNG failed', e);
}
}
// Overwrite on-disk file with current local scene (resolves conflict)
async resolveConflictOverwrite(): Promise<void> {
try {
const host: any = this.editorEl?.nativeElement;
const scene = host?.getScene ? host.getScene() : this.scene();
if (!scene) return;
this.isSaving.set(true);
await firstValueFrom(this.files.putForce(this.path, scene));
this.lastSavedHash = this.hashScene(scene);
this.dirty.set(false);
this.isSaving.set(false);
this.hasConflict.set(false);
this.showToast('Conflit résolu: fichier écrasé', 'success');
} catch (e) {
console.error('Overwrite error:', e);
this.isSaving.set(false);
this.showToast('Échec de l\'écrasement', 'error');
}
}
// Ctrl+S triggers manual save
@HostListener('window:keydown', ['$event'])
onKeyDown(ev: KeyboardEvent) {
if ((ev.ctrlKey || ev.metaKey) && (ev.key === 's' || ev.key === 'S')) {
ev.preventDefault();
this.saveNow();
}
// F11 toggles fullscreen
if (ev.key === 'F11') {
ev.preventDefault();
this.toggleFullscreen();
}
}
// Manual save now using the current scene from the editor API
async saveNow(): Promise<void> {
console.log('💾 Manual save triggered');
try {
const host: any = this.editorEl?.nativeElement;
if (!host) {
console.error('❌ No host element');
this.showToast('Éditeur non prêt', 'error');
return;
}
// Ensure the editor API is ready before proceeding
if (!host.__excalidrawAPI) {
const gotReady = await this.waitForReady(host, 1500);
if (!gotReady) {
console.warn('⏳ Save aborted: editor API not ready');
this.showToast('Éditeur en cours d’initialisation, réessayez', 'error');
return;
}
}
// Proactively ask the web component to dispatch a fresh scene event
try { host.__emitSceneChange?.(); } catch {}
// Yield to next frames to ensure Excalidraw applied last input
await this.awaitNextFrame();
await this.awaitNextFrame();
// Try multiple ways to get the scene to avoid race conditions
let scene = this.getCurrentSceneFromHost(host) || null;
const stateScene = this.scene();
if (!scene || (Array.isArray((scene as any).elements) ? (scene as any).elements.length : 0) < (Array.isArray((stateScene as any)?.elements) ? (stateScene as any).elements.length : 0)) {
scene = stateScene as any;
}
if (!scene || (Array.isArray((scene as any).elements) ? (scene as any).elements.length : 0) === 0) {
// If we have a recent scene from events with elements, use it immediately
const latest = this.latestSceneFromEvents;
const latestLen = Array.isArray((latest as any)?.elements) ? (latest as any).elements.length : 0;
const currentLen = Array.isArray((scene as any)?.elements) ? (scene as any).elements.length : 0;
if (latestLen > currentLen) {
scene = latest as any;
}
// Register wait BEFORE flushing to not miss the event
const waitPromise = this.waitForNextSceneChange(host, 3000);
try { host.__emitSceneChange?.(); } catch {}
const waited = await waitPromise;
const viaHostAfter = this.getCurrentSceneFromHost(host);
const candidates = [scene, waited, viaHostAfter, this.latestSceneFromEvents, this.scene()].filter(Boolean) as ExcalidrawScene[];
let best: ExcalidrawScene | null = null;
for (const c of candidates) {
const len = Array.isArray((c as any).elements) ? (c as any).elements.length : 0;
const bestLen = best && Array.isArray((best as any).elements) ? (best as any).elements.length : 0;
if (!best || len > bestLen) best = c;
}
scene = best;
}
if (scene) {
console.log('🧩 Snapshot', {
elements: Array.isArray((scene as any).elements) ? (scene as any).elements.length : 'n/a',
hasFiles: !!(scene as any).files && Object.keys((scene as any).files || {}).length,
});
}
if (!scene) {
console.error('❌ No scene data');
this.showToast('Aucune donnée à sauvegarder', 'error');
return;
}
console.log('📤 Sending save request...');
this.isSaving.set(true);
this.error.set(null);
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);
result = await firstValueFrom(this.files.putText(this.path, md));
} else {
// For .excalidraw or .json let server persist plain JSON
result = await firstValueFrom(this.files.put(this.path, scene));
}
console.log('📥 Save response', result);
if (result && typeof result.rev === 'string' && result.rev.length > 0) {
const expectedHash = this.hashScene(scene);
// Verify by reloading from server and comparing hash
const reloaded = await firstValueFrom(this.files.get(this.path));
const actualHash = this.hashScene(reloaded);
const ok = expectedHash === actualHash;
console.log('🔁 Verify after save', {
ok,
expectedHash: expectedHash.slice(0,10),
actualHash: actualHash.slice(0,10),
lastSavedHash: this.lastSavedHash?.slice(0,10) || 'none',
elementsCount: Array.isArray((scene as any).elements) ? (scene as any).elements.length : 0
});
if (!ok) {
this.isSaving.set(false);
this.dirty.set(true);
this.showToast('Vérification échouée: rechargement conseillé', 'error');
return;
}
this.lastSavedHash = actualHash;
this.dirty.set(false);
this.isSaving.set(false);
this.hasConflict.set(false);
console.log('✅ Manual save successful');
this.showToast('Sauvegarde réussie', 'success');
} else {
console.error('❌ Save returned no rev');
this.isSaving.set(false);
this.showToast('Erreur de sauvegarde', 'error');
return;
}
} catch (e: any) {
console.error('❌ Manual save failed:', 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);
this.showToast('Conflit détecté', 'error');
} else {
this.error.set('Erreur de sauvegarde.');
this.showToast('Erreur de sauvegarde', 'error');
}
this.isSaving.set(false);
}
}
// More robust scene retrieval from the web component host / API
private getCurrentSceneFromHost(host: any): ExcalidrawScene | null {
try {
const api = host?.__excalidrawAPI;
const hasGetScene = typeof host?.getScene === 'function';
const hasUnderscoreGet = typeof host?.__getScene === 'function';
const hasLastEventGet = typeof host?.__getLastEventScene === 'function';
console.log('🔎 Scene access methods', { hasGetScene, hasUnderscoreGet, hasLastEventGet, apiAvailable: !!api });
const candidates: ExcalidrawScene[] = [] as any;
const viaPublic = hasGetScene ? host.getScene() : null;
if (viaPublic && typeof viaPublic === 'object') {
try { console.log('📸 viaPublic elements', Array.isArray(viaPublic.elements) ? viaPublic.elements.length : 'n/a'); } catch {}
candidates.push(viaPublic);
}
const viaPrivate = hasUnderscoreGet ? host.__getScene() : null;
if (viaPrivate && typeof viaPrivate === 'object') {
try { console.log('📸 viaPrivate elements', Array.isArray(viaPrivate.elements) ? viaPrivate.elements.length : 'n/a'); } catch {}
candidates.push(viaPrivate);
}
const viaLast = hasLastEventGet ? host.__getLastEventScene() : null;
if (viaLast && typeof viaLast === 'object') {
try { console.log('📸 viaLastEvent elements', Array.isArray(viaLast.elements) ? viaLast.elements.length : 'n/a'); } catch {}
candidates.push(viaLast);
}
if (api) {
const elements = api.getSceneElements?.() ?? [];
const appState = api.getAppState?.() ?? {};
const files = api.getFiles?.() ?? {};
try { console.log('📸 viaAPI elements', Array.isArray(elements) ? elements.length : 'n/a'); } catch {}
candidates.push({ elements, appState, files } as ExcalidrawScene);
}
if (candidates.length) {
let best = candidates[0];
for (const c of candidates) {
const len = Array.isArray((c as any).elements) ? (c as any).elements.length : 0;
const bestLen = Array.isArray((best as any).elements) ? (best as any).elements.length : 0;
if (len > bestLen) best = c;
}
return best;
}
} catch (err) {
console.warn('⚠️ Failed to get scene from host/api:', err);
}
return null;
}
private waitForReady(host: any, timeoutMs = 1500): Promise<boolean> {
return new Promise((resolve) => {
if (host?.__excalidrawAPI) return resolve(true);
let done = false;
const onReady = () => { if (!done) { done = true; resolve(true); } };
try { host.addEventListener('ready', onReady, { once: true }); } catch {}
setTimeout(() => {
if (done) return;
done = true;
try { host.removeEventListener('ready', onReady as any); } catch {}
resolve(!!host?.__excalidrawAPI);
}, timeoutMs);
});
}
// Reload from disk to resolve conflict
reloadFromDisk(): void {
if (!this.path) return;
this.isLoading.set(true);
this.files.get(this.path).subscribe({
next: (scene) => {
const transformed = this.prepareSceneData(scene);
this.scene.set(transformed);
this.lastSavedHash = this.hashScene(transformed);
this.dirty.set(false);
this.error.set(null);
this.hasConflict.set(false);
if (this.excalidrawReady && this.editorEl?.nativeElement) {
this.updateExcalidrawScene(transformed);
}
this.isLoading.set(false);
this.showToast('Rechargé depuis le disque', 'info');
},
error: (err) => {
console.error('Reload error:', err);
this.error.set('Erreur lors du rechargement.');
this.isLoading.set(false);
this.showToast('Erreur de rechargement', 'error');
},
});
}
// Simple toast helper
private showToast(message: string, type: 'success' | 'error' | 'info' = 'info'): void {
this.toastMessage.set(message);
this.toastType.set(type);
// auto-hide after 2.5s
setTimeout(() => {
this.toastMessage.set(null);
}, 2500);
}
private setupExcalidraw() {
if (!this.path) {
this.error.set('Aucun chemin de dessin fourni.');
return;
}
this.isLoading.set(true);
// Load initial scene
this.files.get(this.path).subscribe({
next: (scene) => {
try {
// Transform the scene data to match Excalidraw's expected format
const transformedScene = this.prepareSceneData(scene);
this.scene.set(transformedScene);
this.lastSavedHash = this.hashScene(transformedScene);
this.dirty.set(false);
this.error.set(null);
// If Excalidraw is already ready, update the scene
if (this.excalidrawReady && this.editorEl?.nativeElement) {
this.updateExcalidrawScene(transformedScene);
}
} catch (err) {
console.error('Error processing scene data:', err);
this.error.set('Format de dessin non supporté.');
} finally {
this.isLoading.set(false);
}
},
error: (err) => {
console.error('Error loading drawing:', err);
this.error.set('Erreur de chargement du dessin.');
this.isLoading.set(false);
},
});
}
private prepareSceneData(scene: ExcalidrawScene) {
return {
elements: Array.isArray(scene.elements) ? scene.elements : [],
appState: {
viewBackgroundColor: '#1e1e1e',
currentItemFontFamily: 1,
theme: this.themeName(),
...(scene.appState || {})
},
scrollToContent: true,
...(scene.files && { files: scene.files })
};
}
private updateExcalidrawScene(scene: any) {
if (!this.editorEl?.nativeElement) return;
// Try both methods to set the scene
if (this.editorEl.nativeElement.setScene) {
this.editorEl.nativeElement.setScene(scene);
} else if (this.editorEl.nativeElement.__excalidrawAPI?.updateScene) {
this.editorEl.nativeElement.__excalidrawAPI.updateScene(scene);
} else if (this.editorEl.nativeElement.__excalidrawAPI?.setState) {
this.editorEl.nativeElement.__excalidrawAPI.setState({
...scene,
commitToHistory: false
});
}
}
onExcalidrawReady() {
console.log('🎨 Excalidraw Ready - Binding listeners');
this.excalidrawReady = true;
try {
const host: any = this.editorEl?.nativeElement;
console.log('[Angular] READY handler host check', {
hasHost: !!host,
hasAPI: !!host?.__excalidrawAPI,
hasGetScene: typeof host?.getScene === 'function',
has__getScene: typeof host?.__getScene === 'function'
});
} catch {}
const scene = this.scene();
if (scene) {
this.updateExcalidrawScene(scene);
}
// Bind listeners NOW when the editor is confirmed ready
this.bindEditorHostListeners();
}
ngAfterViewInit(): void {
// Fallback: ensure bindings are attached even if the (ready) event never fires
queueMicrotask(() => this.ensureHostListenersFallback());
// Listen for fullscreen changes
document.addEventListener('fullscreenchange', () => {
this.isFullscreen.set(!!document.fullscreenElement);
});
}
private ensureHostListenersFallback(attempt = 0): void {
if (this.hostListenersBound) {
return;
}
const host = this.editorEl?.nativeElement;
if (!host) {
if (attempt >= 20) {
console.warn('[Angular] Fallback binding aborted - host still missing');
return;
}
setTimeout(() => this.ensureHostListenersFallback(attempt + 1), 100);
return;
}
console.warn('[Angular] Fallback binding listeners without ready event', { attempt });
this.excalidrawReady = true;
const scene = this.scene();
if (scene) {
this.updateExcalidrawScene(scene);
}
this.bindEditorHostListeners();
}
// Ensure we bind to the web component once it exists
private bindEditorHostListeners(): void {
if (this.hostListenersBound) return;
const host = this.editorEl?.nativeElement;
if (!host) {
console.warn('⚠️ Cannot bind listeners - host element not found');
return;
}
console.log('🔗 Binding Excalidraw host listeners', {
path: this.path,
lastSavedHash: (this.lastSavedHash || 'null').slice(0, 10)
});
this.hostListenersBound = true;
// Raw debug listeners to validate event propagation
try {
console.log('[Angular] 🎧 Attaching raw event listeners to host', { hostTagName: host.tagName });
host.addEventListener('ready', (e: any) => {
console.log('[Angular] (raw) READY received on host', e?.detail);
});
host.addEventListener('scene-change', (e: any) => {
const det = e?.detail;
const elCount = Array.isArray(det?.elements) ? det.elements.length : 'n/a';
console.log('[Angular] (raw) SCENE-CHANGE received on host', { elCount, detail: det });
});
document.addEventListener('scene-change', (e: any) => {
const det = e?.detail;
const elCount = Array.isArray(det?.elements) ? det.elements.length : 'n/a';
console.log('[Angular] (raw) SCENE-CHANGE received on document', { elCount });
});
console.log('[Angular] ✅ Raw event listeners attached successfully');
} catch (err) {
console.error('[Angular] ❌ Failed to attach raw listeners', err);
}
const sceneChange$ = fromEvent<CustomEvent>(host, 'scene-change').pipe(
debounceTime(250), // Short debounce for responsiveness
map((event) => {
const viaHost = this.getCurrentSceneFromHost(host);
const detail = event?.detail as Partial<ExcalidrawScene> | undefined;
const viaDetail = detail ? this.normalizeSceneDetail(detail) : null;
const candidates = [viaHost, viaDetail].filter(Boolean) as ExcalidrawScene[];
if (candidates.length === 0) {
console.warn('[Angular] Scene-change emitted without retrievable scene data');
return null;
}
// prefer the candidate with the highest number of elements
let best: ExcalidrawScene = candidates[0];
for (const c of candidates) {
const len = Array.isArray((c as any).elements) ? (c as any).elements.length : 0;
const bestLen = Array.isArray((best as any).elements) ? (best as any).elements.length : 0;
if (len > bestLen) best = c;
}
return best;
}),
filter((scene): scene is ExcalidrawScene => !!scene),
map(scene => ({ scene, hash: this.hashScene(scene) }))
);
// Subscription 1: Update the UI dirty status
this.dirtyCheckSub = sceneChange$.subscribe(({ hash }) => {
// For UX: immediately mark as dirty on any scene-change
console.log('✏️ Dirty flagged (event)', {
lastSaved: (this.lastSavedHash || 'null').slice(0, 10),
current: hash.slice(0, 10)
});
if (!this.dirty()) this.dirty.set(true);
});
this.sceneUpdateSub = sceneChange$.subscribe(({ scene }) => {
try {
const elements = Array.isArray((scene as any).elements) ? (scene as any).elements : [];
const appState = (scene as any).appState || {};
const files = (scene as any).files || {};
const updated = { elements, appState, files } as ExcalidrawScene;
this.scene.set(updated);
this.latestSceneFromEvents = updated;
} catch {}
});
// Fallback polling: update dirty flag even if events don't fire
this.pollSub = interval(1000).subscribe(() => {
const current = this.getCurrentSceneFromHost(host);
if (!current) return;
const hash = this.hashScene(current);
const isDirty = this.lastSavedHash !== hash;
if (isDirty !== this.dirty()) {
console.log('🕒 Dirty check (poll)', {
lastSaved: (this.lastSavedHash || 'null').slice(0, 10),
current: hash.slice(0, 10),
isDirty
});
this.dirty.set(isDirty);
}
});
// 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();
console.log('✓ Dirty check and Autosave subscriptions active');
// Cleanup on destroy
this.destroyRef.onDestroy(() => {
console.log('🧹 Cleaning up listeners');
this.saveSub?.unsubscribe();
this.dirtyCheckSub?.unsubscribe();
this.sceneUpdateSub?.unsubscribe();
this.pollSub?.unsubscribe();
this.saveSub = null;
this.dirtyCheckSub = null;
this.sceneUpdateSub = null;
this.pollSub = null;
this.hostListenersBound = false;
});
}
ngOnDestroy(): void {
this.saveSub?.unsubscribe();
// Clean up fullscreen listener
document.removeEventListener('fullscreenchange', () => {
this.isFullscreen.set(!!document.fullscreenElement);
});
}
private awaitNextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
@HostListener('window:beforeunload', ['$event'])
handleBeforeUnload(event: BeforeUnloadEvent): string | null {
if (this.dirty()) {
event.preventDefault();
// @ts-ignore - for older browsers
event.returnValue = '';
return '';
}
return null;
}
async exportPNG(): Promise<void> {
try {
const host = this.editorEl?.nativeElement;
if (!host || !this.excalidrawReady) {
console.error('Excalidraw host element not found');
this.showToast('Éditeur non prêt', 'error');
return;
}
const blob = await this.previews.exportPNGFromElement(host, true);
if (!blob) {
console.error('Failed to generate PNG blob');
this.showToast('Export PNG indisponible', 'error');
return;
}
const base = String(this.path || '').replace(/\.excalidraw(?:\.md)?$/i, '');
const target = `${base}.png`;
await firstValueFrom(this.files.putBinary(target, blob, 'image/png'));
this.error.set(null);
} catch (err) {
console.error('Error exporting PNG:', err);
this.error.set('Erreur lors de l\'export en PNG');
this.showToast('Erreur export PNG', 'error');
}
}
async exportSVG(): Promise<void> {
try {
const host = this.editorEl?.nativeElement;
if (!host || !this.excalidrawReady) {
console.error('Excalidraw host element not found or not ready');
this.showToast('Éditeur non prêt', 'error');
return;
}
const blob = await this.previews.exportSVGFromElement(host, true);
if (!blob) {
console.error('Failed to generate SVG blob');
this.showToast('Export SVG indisponible', 'error');
return;
}
const base = String(this.path || '').replace(/\.excalidraw(?:\.md)?$/i, '');
const target = `${base}.svg`;
await firstValueFrom(this.files.putBinary(target, blob, 'image/svg+xml'));
this.error.set(null);
} catch (err) {
console.error('Error exporting SVG:', err);
this.error.set('Erreur lors de l\'export en SVG');
this.showToast('Erreur export SVG', 'error');
}
}
toggleFullscreen(): void {
if (!document.fullscreenEnabled) {
this.showToast('Mode pleine écran non supporté', 'error');
return;
}
if (document.fullscreenElement) {
// Sortir du mode pleine écran
document.exitFullscreen().catch(err => {
console.error('Erreur lors de la sortie du mode pleine écran:', err);
this.showToast('Erreur sortie pleine écran', 'error');
});
} else {
// Entrer en mode pleine écran
const element = this.editorEl?.nativeElement?.parentElement;
if (element) {
element.requestFullscreen().catch(err => {
console.error('Erreur lors de l\'entrée en mode pleine écran:', err);
this.showToast('Erreur entrée pleine écran', 'error');
});
}
}
}
private hashScene(scene: ExcalidrawScene | null): string {
try {
if (!scene) return '';
// Normalize elements: remove volatile properties
const normEls = Array.isArray(scene.elements) ? scene.elements.map((el: any) => {
const { version, versionNonce, updated, ...rest } = el || {};
return rest;
}) : [];
// Stable sort of elements by id to avoid hash changes due to order
const sortedEls = normEls.slice().sort((a: any, b: any) => (a?.id || '').localeCompare(b?.id || ''));
// Stable sort of files keys to avoid hash changes due to key order
const filesObj = scene.files && typeof scene.files === 'object' ? scene.files : {};
const sortedFilesKeys = Object.keys(filesObj).sort();
const sortedFiles: Record<string, any> = {};
for (const k of sortedFilesKeys) sortedFiles[k] = filesObj[k];
const stable = { elements: sortedEls, files: sortedFiles };
return btoa(unescape(encodeURIComponent(JSON.stringify(stable))));
} catch (error) {
console.error('Error hashing scene:', error);
return '';
}
}
private normalizeSceneDetail(detail: Partial<ExcalidrawScene>): ExcalidrawScene {
const elements = Array.isArray(detail?.elements) ? detail.elements : [];
const appState = typeof detail?.appState === 'object' && detail?.appState ? detail.appState : {};
const files = typeof detail?.files === 'object' && detail?.files ? detail.files : {};
return { elements, appState, files };
}
}
@@ -0,0 +1,102 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, map, tap } from 'rxjs';
export type ThemeName = 'light' | 'dark';
export interface ExcalidrawScene {
elements: any[];
appState?: Record<string, any>;
files?: Record<string, any>;
}
@Injectable({ providedIn: 'root' })
export class DrawingsFileService {
private etagCache = new Map<string, string>();
constructor(private http: HttpClient) {}
get(path: string): Observable<ExcalidrawScene> {
const url = `/api/files`;
return this.http.get<ExcalidrawScene>(url, {
params: { path: path },
observe: 'response'
}).pipe(
tap((res) => {
const etag = res.headers.get('ETag');
if (etag) this.etagCache.set(path, etag);
}),
map((res) => res.body as ExcalidrawScene)
);
}
put(path: string, scene: ExcalidrawScene): Observable<{ rev: string }> {
const url = `/api/files`;
const prev = this.etagCache.get(path);
let headers = new HttpHeaders({ 'Content-Type': 'application/json' });
if (prev) headers = headers.set('If-Match', prev);
return this.http.put<{ rev: string }>(url, scene, {
params: { path: path },
headers,
observe: 'response'
}).pipe(
tap((res) => {
const status = res.status;
const etag = res.headers.get('ETag');
console.log('[FilesService.put] status', status, 'path', path, 'rev', res.body?.rev);
if (etag) this.etagCache.set(path, etag);
}),
map((res) => res.body as { rev: string })
);
}
// Write raw text/markdown content (used for pre-compressed Obsidian Excalidraw MD)
putText(path: string, markdown: string): Observable<{ rev: string }> {
const url = `/api/files`;
const prev = this.etagCache.get(path);
let headers = new HttpHeaders({ 'Content-Type': 'text/markdown; charset=utf-8' });
if (prev) headers = headers.set('If-Match', prev);
return this.http.put<{ rev: string }>(url, markdown, {
params: { path: path },
headers,
observe: 'response'
}).pipe(
tap((res) => {
const status = res.status;
const etag = res.headers.get('ETag');
console.log('[FilesService.putText] status', status, 'path', path, 'rev', res.body?.rev);
if (etag) this.etagCache.set(path, etag);
}),
map((res) => res.body as { rev: string })
);
}
// Force write without If-Match (overwrite on disk)
putForce(path: string, scene: ExcalidrawScene): Observable<{ rev: string }> {
const url = `/api/files`;
const headers = new HttpHeaders({ 'Content-Type': 'application/json' });
return this.http.put<{ rev: string }>(url, scene, {
params: { path: path },
headers,
observe: 'response'
}).pipe(
tap((res) => {
const status = res.status;
const etag = res.headers.get('ETag');
console.log('[FilesService.putForce] status', status, 'path', path, 'rev', res.body?.rev);
if (etag) this.etagCache.set(path, etag);
}),
map((res) => res.body as { rev: string })
);
}
putBinary(path: string, blob: Blob, mime: string): Observable<{ ok: true }> {
const url = `/api/files/blob`;
return this.http.put<{ ok: true }>(url, blob, {
params: { path: path },
headers: new HttpHeaders({ 'Content-Type': mime }),
});
}
}
@@ -0,0 +1,16 @@
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class DrawingsPreviewService {
async exportPNGFromElement(el: HTMLElement, withBackground = true): Promise<Blob | undefined> {
const anyEl: any = el as any;
if (!anyEl?.exportPNG) return undefined;
return await anyEl.exportPNG({ withBackground });
}
async exportSVGFromElement(el: HTMLElement, withBackground = true): Promise<Blob | undefined> {
const anyEl: any = el as any;
if (!anyEl?.exportSVG) return undefined;
return await anyEl.exportSVG({ withBackground });
}
}
@@ -0,0 +1,166 @@
import { Injectable } from '@angular/core';
import * as LZString from 'lz-string';
export interface ExcalidrawScene {
elements: any[];
appState?: Record<string, any>;
files?: Record<string, any>;
}
/**
* Service for parsing and serializing Excalidraw files in Obsidian format
*/
@Injectable({ providedIn: 'root' })
export class ExcalidrawIoService {
/**
* Extract front matter from markdown content
*/
extractFrontMatter(md: string): string | null {
if (!md) return null;
const match = md.match(/^---\s*\n([\s\S]*?)\n---/);
return match ? match[0] : null;
}
/**
* Parse Obsidian Excalidraw markdown format
* Extracts compressed-json block and decompresses using LZ-String
*/
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);
if (compressedMatch && compressedMatch[1]) {
try {
// Remove whitespace from base64 data
const compressed = compressedMatch[1].replace(/\s+/g, '');
// Decompress using LZ-String
const decompressed = LZString.decompressFromBase64(compressed);
if (!decompressed) {
console.warn('[Excalidraw] LZ-String decompression returned null');
return null;
}
// Parse JSON
const data = JSON.parse(decompressed);
return this.normalizeScene(data);
} catch (error) {
console.error('[Excalidraw] Failed to parse compressed-json:', error);
return null;
}
}
// Fallback: try to extract plain json block
const jsonMatch = md.match(/```\s*(?:excalidraw|json)\s*\n([\s\S]*?)\n```/i);
if (jsonMatch && jsonMatch[1]) {
try {
const data = JSON.parse(jsonMatch[1].trim());
return this.normalizeScene(data);
} catch (error) {
console.error('[Excalidraw] Failed to parse json block:', error);
return null;
}
}
return null;
}
/**
* Parse flat JSON format (legacy ObsiViewer format)
*/
parseFlatJson(text: string): ExcalidrawScene | null {
if (!text || typeof text !== 'string') return null;
try {
const data = JSON.parse(text);
// Validate it has the expected structure
if (data && typeof data === 'object' && Array.isArray(data.elements)) {
return this.normalizeScene(data);
}
return null;
} catch (error) {
return null;
}
}
/**
* Convert Excalidraw scene to Obsidian markdown format
*/
toObsidianMd(data: ExcalidrawScene, existingFrontMatter?: string | null): string {
// Normalize scene data
const scene = this.normalizeScene(data);
// Serialize to JSON
const json = JSON.stringify(scene);
// Compress using LZ-String
const compressed = LZString.compressToBase64(json);
// Use existing front matter or create default
const frontMatter = existingFrontMatter?.trim() || `---
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
return `${frontMatter}
${banner}
# Excalidraw Data
## Text Elements
%%
## Drawing
\`\`\`compressed-json
${compressed}
\`\`\`
%%`;
}
/**
* Parse Excalidraw content from any supported format
* Tries Obsidian MD format first, then falls back to flat JSON
*/
parseAny(text: string): ExcalidrawScene | null {
// Try Obsidian format first
let data = this.parseObsidianMd(text);
if (data) return data;
// Fallback to flat JSON
data = this.parseFlatJson(text);
if (data) return data;
return null;
}
/**
* Normalize scene structure
*/
private normalizeScene(data: any): ExcalidrawScene {
return {
elements: Array.isArray(data?.elements) ? data.elements : [],
appState: (data && typeof data.appState === 'object') ? data.appState : {},
files: (data && typeof data.files === 'object') ? data.files : {}
};
}
/**
* Validate Excalidraw scene structure
*/
isValidScene(data: any): boolean {
if (!data || typeof data !== 'object') return false;
if (!Array.isArray(data.elements)) return false;
return true;
}
}