306 lines
9.1 KiB
TypeScript
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);
|
|
}
|
|
}
|