import { Component, Input, OnChanges, SimpleChanges, signal, computed, inject, effect, CUSTOM_ELEMENTS_SCHEMA, Output, EventEmitter } from '@angular/core'; import { CommonModule } from '@angular/common'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { MarkdownService } from '../../services/markdown.service'; import { Note } from '../../types'; import { DrawingsEditorComponent } from '../../app/features/drawings/drawings-editor.component'; import { EditorStateService } from '../../services/editor-state.service'; /** * Composant réutilisable pour afficher du contenu Markdown * * Features: * - Rendu markdown complet (GFM, callouts, math, mermaid, etc.) * - Support des fichiers .excalidraw.md avec éditeur intégré * - Lazy loading des images * - Mode plein écran * - Syntax highlighting avec highlight.js * - Support des WikiLinks et tags inline * * @example * ```html * * * ``` */ @Component({ selector: 'app-markdown-viewer', standalone: true, imports: [CommonModule, DrawingsEditorComponent], schemas: [CUSTOM_ELEMENTS_SCHEMA], template: `
{{ isExcalidrawFile() ? 'Excalidraw Drawing' : 'Markdown Document' }}
{{ error() }}
`, styles: [` :host { display: block; height: 100%; } .markdown-viewer { display: flex; flex-direction: column; height: 100%; background-color: var(--bg-main); } .markdown-viewer__toolbar { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); background-color: var(--card); } .markdown-viewer__toolbar-left, .markdown-viewer__toolbar-right { display: flex; align-items: center; gap: 0.5rem; } .markdown-viewer__content { flex: 1; overflow-y: auto; padding: 2rem; } .markdown-viewer__excalidraw { flex: 1; overflow: hidden; } .markdown-viewer__error, .markdown-viewer__loading { flex: 1; display: flex; align-items: center; justify-content: center; padding: 2rem; } .markdown-viewer--fullscreen { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; background-color: var(--bg-main); } /* Responsive padding */ @media (max-width: 768px) { .markdown-viewer__content { padding: 1rem; } } /* Lazy loading images */ :host ::ng-deep .markdown-viewer__content img { opacity: 0; transition: opacity 0.3s ease; } :host ::ng-deep .markdown-viewer__content img.loaded { opacity: 1; } /* Smooth scrolling */ .markdown-viewer__content { scroll-behavior: smooth; } `] }) export class MarkdownViewerComponent implements OnChanges { private markdownService = inject(MarkdownService); private sanitizer = inject(DomSanitizer); private editorState = inject(EditorStateService); /** Contenu markdown brut à afficher */ @Input() content: string = ''; /** Liste de toutes les notes pour la résolution des WikiLinks */ @Input() allNotes: Note[] = []; /** Note courante pour la résolution des chemins relatifs */ @Input() currentNote?: Note; /** Afficher la barre d'outils */ @Input() showToolbar: boolean = true; /** Activer le mode plein écran */ @Input() fullscreenMode: boolean = false; /** Chemin du fichier (pour détecter les .excalidraw.md) */ @Input() filePath: string = ''; /** Event émis quand on veut passer en mode édition */ @Output() editModeRequested = new EventEmitter<{ path: string; content: string }>(); // Signals isLoading = signal(false); error = signal(null); isFullscreen = signal(false); isExcalidrawFile = computed(() => { return this.filePath.toLowerCase().endsWith('.excalidraw.md'); }); excalidrawPath = computed(() => { return this.isExcalidrawFile() ? this.filePath : ''; }); renderedHtml = computed(() => { if (!this.content || this.isExcalidrawFile()) { return ''; } try { const html = this.markdownService.render( this.content, this.allNotes, this.currentNote ); return this.sanitizer.bypassSecurityTrustHtml(html); } catch (err) { console.error('Markdown render error:', err); this.error.set(`Erreur de rendu: ${err}`); return ''; } }); constructor() { // Setup lazy loading for images effect(() => { if (!this.isExcalidrawFile()) { this.setupLazyLoading(); } }); } ngOnChanges(changes: SimpleChanges): void { if (changes['content'] || changes['filePath']) { this.error.set(null); } } toggleFullscreen(): void { this.isFullscreen.update(v => !v); } toggleEditMode(): void { if (!this.filePath) { console.warn('[MarkdownViewer] Cannot edit: no file path'); return; } // Émettre l'événement avec le chemin et le contenu this.editModeRequested.emit({ path: this.filePath, content: this.content }); } private setupLazyLoading(): void { // Wait for next tick to ensure DOM is updated setTimeout(() => { const images = document.querySelectorAll('.markdown-viewer__content img:not(.loaded)'); if ('IntersectionObserver' in window) { const imageObserver = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target as HTMLImageElement; img.classList.add('loaded'); observer.unobserve(img); } }); }, { rootMargin: '50px' }); images.forEach(img => imageObserver.observe(img)); } else { // Fallback: load all images immediately images.forEach(img => img.classList.add('loaded')); } }, 0); } }