feat: add Meilisearch backend integration with Docker Compose setup and Excalidraw support
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user