ObsiViewer/src/components/markdown-viewer/markdown-viewer.component.ts

306 lines
9.1 KiB
TypeScript

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
* <app-markdown-viewer
* [content]="markdownContent"
* [allNotes]="notes"
* [currentNote]="note"
* [fullscreenMode]="true">
* </app-markdown-viewer>
* ```
*/
@Component({
selector: 'app-markdown-viewer',
standalone: true,
imports: [CommonModule, DrawingsEditorComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<div
class="markdown-viewer"
[class.markdown-viewer--fullscreen]="isFullscreen()"
[class.markdown-viewer--excalidraw]="isExcalidrawFile()">
<!-- Toolbar -->
<div class="markdown-viewer__toolbar" *ngIf="showToolbar">
<div class="markdown-viewer__toolbar-left">
<span class="text-sm text-muted">
{{ isExcalidrawFile() ? 'Excalidraw Drawing' : 'Markdown Document' }}
</span>
</div>
<div class="markdown-viewer__toolbar-right">
<!-- Edit Button -->
<button
*ngIf="!isExcalidrawFile()"
type="button"
class="btn-standard-icon"
(click)="toggleEditMode()"
[attr.aria-label]="'Edit markdown'">
<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="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<!-- Fullscreen Button -->
<button
*ngIf="fullscreenMode"
type="button"
class="btn-standard-icon"
(click)="toggleFullscreen()"
[attr.aria-label]="isFullscreen() ? 'Exit fullscreen' : 'Enter fullscreen'">
<svg *ngIf="!isFullscreen()" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
</svg>
<svg *ngIf="isFullscreen()" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/>
</svg>
</button>
</div>
</div>
<!-- Excalidraw Editor -->
<div *ngIf="isExcalidrawFile()" class="markdown-viewer__excalidraw">
<app-drawings-editor [path]="excalidrawPath()"></app-drawings-editor>
</div>
<!-- Markdown Content -->
<div
*ngIf="!isExcalidrawFile()"
class="markdown-viewer__content prose prose-slate dark:prose-invert max-w-none"
[innerHTML]="renderedHtml()">
</div>
<!-- Error State -->
<div *ngIf="error()" class="markdown-viewer__error">
<div class="text-red-500 dark:text-red-400">
<svg class="inline-block w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ error() }}
</div>
</div>
<!-- Loading State -->
<div *ngIf="isLoading()" class="markdown-viewer__loading">
<div class="flex items-center justify-center p-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-brand"></div>
</div>
</div>
</div>
`,
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<boolean>(false);
error = signal<string | null>(null);
isFullscreen = signal<boolean>(false);
isExcalidrawFile = computed(() => {
return this.filePath.toLowerCase().endsWith('.excalidraw.md');
});
excalidrawPath = computed(() => {
return this.isExcalidrawFile() ? this.filePath : '';
});
renderedHtml = computed<SafeHtml>(() => {
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);
}
}