feat: add markdown playground component view toggle and attachment handling

This commit is contained in:
2025-10-15 21:05:31 -04:00
parent 7fd4f5bf8e
commit c2315735ff
15 changed files with 3556 additions and 155 deletions
@@ -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);
}
}
@@ -0,0 +1,172 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SmartFileViewerComponent } from './smart-file-viewer.component';
import { FileTypeDetectorService } from '../../services/file-type-detector.service';
import { MarkdownService } from '../../services/markdown.service';
describe('SmartFileViewerComponent', () => {
let component: SmartFileViewerComponent;
let fixture: ComponentFixture<SmartFileViewerComponent>;
let fileTypeDetector: jasmine.SpyObj<FileTypeDetectorService>;
beforeEach(async () => {
const fileTypeDetectorSpy = jasmine.createSpyObj('FileTypeDetectorService', ['getViewerType']);
const markdownServiceSpy = jasmine.createSpyObj('MarkdownService', ['render']);
await TestBed.configureTestingModule({
imports: [SmartFileViewerComponent],
providers: [
{ provide: FileTypeDetectorService, useValue: fileTypeDetectorSpy },
{ provide: MarkdownService, useValue: markdownServiceSpy }
]
}).compileComponents();
fileTypeDetector = TestBed.inject(FileTypeDetectorService) as jasmine.SpyObj<FileTypeDetectorService>;
fixture = TestBed.createComponent(SmartFileViewerComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should detect markdown viewer type', () => {
fileTypeDetector.getViewerType.and.returnValue('markdown');
component.filePath = 'test.md';
component.content = '# Test';
component.ngOnChanges({
filePath: {
currentValue: 'test.md',
previousValue: '',
firstChange: true,
isFirstChange: () => true
}
});
expect(component.viewerType()).toBe('markdown');
});
it('should detect excalidraw viewer type', () => {
fileTypeDetector.getViewerType.and.returnValue('excalidraw');
component.filePath = 'drawing.excalidraw.md';
component.ngOnChanges({
filePath: {
currentValue: 'drawing.excalidraw.md',
previousValue: '',
firstChange: true,
isFirstChange: () => true
}
});
expect(component.viewerType()).toBe('excalidraw');
});
it('should detect image viewer type', () => {
fileTypeDetector.getViewerType.and.returnValue('image');
component.filePath = 'photo.png';
component.ngOnChanges({
filePath: {
currentValue: 'photo.png',
previousValue: '',
firstChange: true,
isFirstChange: () => true
}
});
expect(component.viewerType()).toBe('image');
});
it('should extract file name correctly', () => {
component.filePath = 'folder/subfolder/test.md';
expect(component.fileName()).toBe('test.md');
});
it('should handle windows paths', () => {
component.filePath = 'folder\\subfolder\\test.md';
expect(component.fileName()).toBe('test.md');
});
it('should construct image src for image files', () => {
fileTypeDetector.getViewerType.and.returnValue('image');
component.filePath = 'images/photo.png';
component.ngOnChanges({
filePath: {
currentValue: 'images/photo.png',
previousValue: '',
firstChange: true,
isFirstChange: () => true
}
});
fixture.detectChanges();
expect(component.imageSrc()).toContain('images/photo.png');
});
it('should handle base64 image content', () => {
fileTypeDetector.getViewerType.and.returnValue('image');
component.content = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
component.ngOnChanges({
content: {
currentValue: component.content,
previousValue: '',
firstChange: true,
isFirstChange: () => true
}
});
expect(component.imageSrc()).toBe(component.content);
});
it('should construct PDF src for PDF files', () => {
fileTypeDetector.getViewerType.and.returnValue('pdf');
component.filePath = 'documents/file.pdf';
component.ngOnChanges({
filePath: {
currentValue: 'documents/file.pdf',
previousValue: '',
firstChange: true,
isFirstChange: () => true
}
});
fixture.detectChanges();
expect(component.pdfSrc()).toContain('documents/file.pdf');
});
it('should update viewer type when file path changes', () => {
fileTypeDetector.getViewerType.and.returnValue('markdown');
component.filePath = 'test.md';
component.ngOnChanges({
filePath: {
currentValue: 'test.md',
previousValue: '',
firstChange: true,
isFirstChange: () => true
}
});
expect(component.viewerType()).toBe('markdown');
fileTypeDetector.getViewerType.and.returnValue('image');
component.filePath = 'test.png';
component.ngOnChanges({
filePath: {
currentValue: 'test.png',
previousValue: 'test.md',
firstChange: false,
isFirstChange: () => false
}
});
expect(component.viewerType()).toBe('image');
});
});
@@ -0,0 +1,169 @@
import { Component, Input, OnChanges, SimpleChanges, signal, computed, inject, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MarkdownViewerComponent } from '../markdown-viewer/markdown-viewer.component';
import { FileTypeDetectorService } from '../../services/file-type-detector.service';
import { Note } from '../../types';
/**
* Composant intelligent qui détecte automatiquement le type de fichier
* et affiche le viewer approprié (Markdown, Excalidraw, Image, etc.)
*
* @example
* ```html
* <app-smart-file-viewer
* [filePath]="note.filePath"
* [content]="note.content"
* [allNotes]="notes"
* [currentNote]="note">
* </app-smart-file-viewer>
* ```
*/
@Component({
selector: 'app-smart-file-viewer',
standalone: true,
imports: [CommonModule, MarkdownViewerComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<div class="smart-file-viewer" [attr.data-viewer-type]="viewerType()">
<!-- Markdown/Excalidraw Viewer -->
<app-markdown-viewer
*ngIf="viewerType() === 'markdown' || viewerType() === 'excalidraw'"
[content]="content"
[allNotes]="allNotes"
[currentNote]="currentNote"
[showToolbar]="showToolbar"
[fullscreenMode]="fullscreenMode"
[filePath]="filePath">
</app-markdown-viewer>
<!-- Image Viewer -->
<div *ngIf="viewerType() === 'image'" class="smart-file-viewer__image">
<img
[src]="imageSrc()"
[alt]="fileName()"
class="max-w-full h-auto rounded-lg shadow-lg"
loading="lazy">
</div>
<!-- PDF Viewer -->
<div *ngIf="viewerType() === 'pdf'" class="smart-file-viewer__pdf">
<iframe
[src]="pdfSrc()"
class="w-full h-full border-0"
title="PDF Viewer">
</iframe>
</div>
<!-- Text Viewer -->
<div *ngIf="viewerType() === 'text'" class="smart-file-viewer__text">
<pre class="p-4 bg-card rounded-lg overflow-auto"><code>{{ content }}</code></pre>
</div>
<!-- Unknown File Type -->
<div *ngIf="viewerType() === 'unknown'" class="smart-file-viewer__unknown">
<div class="flex flex-col items-center justify-center p-8 text-center">
<svg class="w-16 h-16 text-muted mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<h3 class="text-lg font-semibold mb-2">Type de fichier non supporté</h3>
<p class="text-muted">{{ fileName() }}</p>
</div>
</div>
</div>
`,
styles: [`
:host {
display: block;
height: 100%;
}
.smart-file-viewer {
height: 100%;
display: flex;
flex-direction: column;
}
.smart-file-viewer__image,
.smart-file-viewer__pdf,
.smart-file-viewer__text,
.smart-file-viewer__unknown {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
overflow: auto;
}
.smart-file-viewer__image img {
max-height: 90vh;
object-fit: contain;
}
.smart-file-viewer__pdf iframe {
min-height: 600px;
}
.smart-file-viewer__text pre {
width: 100%;
max-width: 1200px;
font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 1.5;
color: var(--text-main);
}
@media (max-width: 768px) {
.smart-file-viewer__image,
.smart-file-viewer__pdf,
.smart-file-viewer__text,
.smart-file-viewer__unknown {
padding: 1rem;
}
}
`]
})
export class SmartFileViewerComponent implements OnChanges {
private fileTypeDetector = inject(FileTypeDetectorService);
@Input() filePath: string = '';
@Input() content: string = '';
@Input() allNotes: Note[] = [];
@Input() currentNote?: Note;
@Input() showToolbar: boolean = true;
@Input() fullscreenMode: boolean = true;
viewerType = signal<'markdown' | 'excalidraw' | 'image' | 'pdf' | 'text' | 'unknown'>('unknown');
fileName = computed(() => {
return this.filePath.split('/').pop() || this.filePath.split('\\').pop() || 'Unknown file';
});
imageSrc = computed(() => {
if (this.viewerType() !== 'image') return '';
// If content is base64, use it directly
if (this.content.startsWith('data:image')) {
return this.content;
}
// Otherwise, construct API path
return `/api/files/${encodeURIComponent(this.filePath)}`;
});
pdfSrc = computed(() => {
if (this.viewerType() !== 'pdf') return '';
return `/api/files/${encodeURIComponent(this.filePath)}`;
});
ngOnChanges(changes: SimpleChanges): void {
if (changes['filePath'] || changes['content']) {
this.detectViewerType();
}
}
private detectViewerType(): void {
const type = this.fileTypeDetector.getViewerType(this.filePath, this.content);
this.viewerType.set(type);
}
}