feat: add markdown playground component view toggle and attachment handling
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MarkdownViewerComponent } from './markdown-viewer.component';
|
||||
import { MarkdownService } from '../../services/markdown.service';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
|
||||
describe('MarkdownViewerComponent', () => {
|
||||
let component: MarkdownViewerComponent;
|
||||
let fixture: ComponentFixture<MarkdownViewerComponent>;
|
||||
let markdownService: jasmine.SpyObj<MarkdownService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const markdownServiceSpy = jasmine.createSpyObj('MarkdownService', ['render']);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [MarkdownViewerComponent],
|
||||
providers: [
|
||||
{ provide: MarkdownService, useValue: markdownServiceSpy }
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
markdownService = TestBed.inject(MarkdownService) as jasmine.SpyObj<MarkdownService>;
|
||||
fixture = TestBed.createComponent(MarkdownViewerComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render markdown content', () => {
|
||||
const testMarkdown = '# Hello World';
|
||||
const expectedHtml = '<h1>Hello World</h1>';
|
||||
|
||||
markdownService.render.and.returnValue(expectedHtml);
|
||||
component.content = testMarkdown;
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(markdownService.render).toHaveBeenCalledWith(testMarkdown, [], undefined);
|
||||
});
|
||||
|
||||
it('should detect excalidraw files', () => {
|
||||
component.filePath = 'test.excalidraw.md';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isExcalidrawFile()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not detect regular markdown as excalidraw', () => {
|
||||
component.filePath = 'test.md';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isExcalidrawFile()).toBe(false);
|
||||
});
|
||||
|
||||
it('should toggle fullscreen mode', () => {
|
||||
expect(component.isFullscreen()).toBe(false);
|
||||
|
||||
component.toggleFullscreen();
|
||||
expect(component.isFullscreen()).toBe(true);
|
||||
|
||||
component.toggleFullscreen();
|
||||
expect(component.isFullscreen()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle render errors gracefully', () => {
|
||||
const testMarkdown = '# Test';
|
||||
markdownService.render.and.throwError('Render error');
|
||||
|
||||
component.content = testMarkdown;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.error()).toContain('Erreur de rendu');
|
||||
});
|
||||
|
||||
it('should clear error on content change', () => {
|
||||
component.error.set('Previous error');
|
||||
component.content = 'New content';
|
||||
|
||||
component.ngOnChanges({
|
||||
content: {
|
||||
currentValue: 'New content',
|
||||
previousValue: 'Old content',
|
||||
firstChange: false,
|
||||
isFirstChange: () => false
|
||||
}
|
||||
});
|
||||
|
||||
expect(component.error()).toBeNull();
|
||||
});
|
||||
|
||||
it('should show toolbar when showToolbar is true', () => {
|
||||
component.showToolbar = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
const toolbar = fixture.nativeElement.querySelector('.markdown-viewer__toolbar');
|
||||
expect(toolbar).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should hide toolbar when showToolbar is false', () => {
|
||||
component.showToolbar = false;
|
||||
fixture.detectChanges();
|
||||
|
||||
const toolbar = fixture.nativeElement.querySelector('.markdown-viewer__toolbar');
|
||||
expect(toolbar).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should pass allNotes to markdown service', () => {
|
||||
const testNotes = [
|
||||
{ id: '1', title: 'Note 1', content: 'Content 1' },
|
||||
{ id: '2', title: 'Note 2', content: 'Content 2' }
|
||||
] as any[];
|
||||
|
||||
markdownService.render.and.returnValue('<p>Test</p>');
|
||||
component.content = '# Test';
|
||||
component.allNotes = testNotes;
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(markdownService.render).toHaveBeenCalledWith('# Test', testNotes, undefined);
|
||||
});
|
||||
|
||||
it('should pass currentNote to markdown service', () => {
|
||||
const currentNote = { id: '1', title: 'Current', content: 'Content' } as any;
|
||||
|
||||
markdownService.render.and.returnValue('<p>Test</p>');
|
||||
component.content = '# Test';
|
||||
component.currentNote = currentNote;
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(markdownService.render).toHaveBeenCalledWith('# Test', [], currentNote);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { Component, Input, OnChanges, SimpleChanges, signal, computed, inject, effect, CUSTOM_ELEMENTS_SCHEMA } 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';
|
||||
|
||||
/**
|
||||
* 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">
|
||||
<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);
|
||||
|
||||
/** 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 = '';
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user