feat: add markdown playground component view toggle and attachment handling
This commit is contained in:
@@ -901,6 +901,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.vaultService.ensureFolderOpen(note.originalPath);
|
||||
this.selectedNoteId.set(note.id);
|
||||
// Ensure we leave other modes (e.g. markdown playground, drawings) and show the note viewer
|
||||
this.activeView.set('files');
|
||||
this.markdownViewerService.setCurrentNote(note);
|
||||
|
||||
if (!this.isDesktopView() && this.activeView() === 'search') {
|
||||
|
||||
@@ -3,13 +3,15 @@ import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MarkdownService } from '../../../../services/markdown.service';
|
||||
import { HttpClient, HttpClientModule } from '@angular/common/http';
|
||||
import { MarkdownViewerComponent } from '../../../../components/markdown-viewer/markdown-viewer.component';
|
||||
|
||||
const DEFAULT_MD_PATH = 'assets/samples/markdown-playground.md';
|
||||
const DEFAULT_MD_PATH_ABS = '/assets/samples/markdown-playground.md';
|
||||
|
||||
@Component({
|
||||
selector: 'app-markdown-playground',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, HttpClientModule],
|
||||
imports: [CommonModule, FormsModule, HttpClientModule, MarkdownViewerComponent],
|
||||
template: `
|
||||
<div class="h-full flex flex-col bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Header -->
|
||||
@@ -28,7 +30,8 @@ const DEFAULT_MD_PATH = 'assets/samples/markdown-playground.md';
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Markdown Source</h2>
|
||||
</div>
|
||||
<textarea
|
||||
[(ngModel)]="sample"
|
||||
[ngModel]="sample()"
|
||||
(ngModelChange)="sample.set($event)"
|
||||
class="flex-1 p-4 font-mono text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 resize-none focus:outline-none"
|
||||
placeholder="Entrez votre Markdown ici..."
|
||||
spellcheck="false"
|
||||
@@ -39,15 +42,35 @@ const DEFAULT_MD_PATH = 'assets/samples/markdown-playground.md';
|
||||
<div class="flex-1 flex flex-col bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-750 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Preview</h2>
|
||||
<button
|
||||
(click)="resetToDefault()"
|
||||
class="text-xs px-3 py-1 rounded bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
(click)="toggleViewMode()"
|
||||
class="text-xs px-3 py-1 rounded bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||
title="Toggle between inline and component view"
|
||||
>
|
||||
{{ useComponentView() ? 'Inline View' : 'Component View' }}
|
||||
</button>
|
||||
<button
|
||||
(click)="resetToDefault()"
|
||||
class="text-xs px-3 py-1 rounded bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
<div class="flex-1 overflow-auto" [class.p-4]="!useComponentView()">
|
||||
<!-- Component View -->
|
||||
<app-markdown-viewer
|
||||
*ngIf="useComponentView()"
|
||||
[content]="sample()"
|
||||
[allNotes]="[]"
|
||||
[showToolbar]="false"
|
||||
[fullscreenMode]="false">
|
||||
</app-markdown-viewer>
|
||||
|
||||
<!-- Inline View -->
|
||||
<div
|
||||
*ngIf="!useComponentView()"
|
||||
class="prose prose-slate dark:prose-invert max-w-none"
|
||||
[innerHTML]="renderedHtml()"
|
||||
></div>
|
||||
@@ -98,6 +121,7 @@ export class MarkdownPlaygroundComponent {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
sample = signal<string>('');
|
||||
useComponentView = signal<boolean>(true);
|
||||
|
||||
renderedHtml = computed(() => {
|
||||
const markdown = this.sample();
|
||||
@@ -117,8 +141,14 @@ export class MarkdownPlaygroundComponent {
|
||||
this.http.get(DEFAULT_MD_PATH, { responseType: 'text' }).subscribe({
|
||||
next: (text) => this.sample.set(text ?? ''),
|
||||
error: (err) => {
|
||||
console.error('Failed to load default markdown:', err);
|
||||
this.sample.set('');
|
||||
console.warn('Fallback: retry loading default markdown via absolute path', err);
|
||||
this.http.get(DEFAULT_MD_PATH_ABS, { responseType: 'text' }).subscribe({
|
||||
next: (text) => this.sample.set(text ?? ''),
|
||||
error: (err2) => {
|
||||
console.error('Failed to load default markdown (both paths):', err2);
|
||||
this.sample.set('');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -126,4 +156,8 @@ export class MarkdownPlaygroundComponent {
|
||||
resetToDefault(): void {
|
||||
this.loadDefaultSample();
|
||||
}
|
||||
|
||||
toggleViewMode(): void {
|
||||
this.useComponentView.update(v => !v);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { FileTypeDetectorService } from './file-type-detector.service';
|
||||
|
||||
describe('FileTypeDetectorService', () => {
|
||||
let service: FileTypeDetectorService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(FileTypeDetectorService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('isExcalidrawFile', () => {
|
||||
it('should detect .excalidraw.md files', () => {
|
||||
expect(service.isExcalidrawFile('test.excalidraw.md')).toBe(true);
|
||||
expect(service.isExcalidrawFile('folder/drawing.excalidraw.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect .excalidraw files', () => {
|
||||
expect(service.isExcalidrawFile('test.excalidraw')).toBe(true);
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
expect(service.isExcalidrawFile('TEST.EXCALIDRAW.MD')).toBe(true);
|
||||
expect(service.isExcalidrawFile('Test.Excalidraw.Md')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-excalidraw files', () => {
|
||||
expect(service.isExcalidrawFile('test.md')).toBe(false);
|
||||
expect(service.isExcalidrawFile('test.txt')).toBe(false);
|
||||
expect(service.isExcalidrawFile('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMarkdownFile', () => {
|
||||
it('should detect .md files', () => {
|
||||
expect(service.isMarkdownFile('test.md')).toBe(true);
|
||||
expect(service.isMarkdownFile('folder/note.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('should exclude .excalidraw.md files', () => {
|
||||
expect(service.isMarkdownFile('test.excalidraw.md')).toBe(false);
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
expect(service.isMarkdownFile('TEST.MD')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-markdown files', () => {
|
||||
expect(service.isMarkdownFile('test.txt')).toBe(false);
|
||||
expect(service.isMarkdownFile('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isImageFile', () => {
|
||||
it('should detect common image formats', () => {
|
||||
expect(service.isImageFile('image.png')).toBe(true);
|
||||
expect(service.isImageFile('photo.jpg')).toBe(true);
|
||||
expect(service.isImageFile('photo.jpeg')).toBe(true);
|
||||
expect(service.isImageFile('icon.svg')).toBe(true);
|
||||
expect(service.isImageFile('animation.gif')).toBe(true);
|
||||
expect(service.isImageFile('image.webp')).toBe(true);
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
expect(service.isImageFile('IMAGE.PNG')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-image files', () => {
|
||||
expect(service.isImageFile('test.md')).toBe(false);
|
||||
expect(service.isImageFile('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileTypeInfo', () => {
|
||||
it('should return correct info for excalidraw files', () => {
|
||||
const info = service.getFileTypeInfo('test.excalidraw.md');
|
||||
expect(info.type).toBe('excalidraw');
|
||||
expect(info.isEditable).toBe(true);
|
||||
expect(info.requiresSpecialViewer).toBe(true);
|
||||
});
|
||||
|
||||
it('should return correct info for markdown files', () => {
|
||||
const info = service.getFileTypeInfo('test.md');
|
||||
expect(info.type).toBe('markdown');
|
||||
expect(info.isEditable).toBe(true);
|
||||
expect(info.requiresSpecialViewer).toBe(false);
|
||||
});
|
||||
|
||||
it('should return correct info for image files', () => {
|
||||
const info = service.getFileTypeInfo('test.png');
|
||||
expect(info.type).toBe('image');
|
||||
expect(info.isEditable).toBe(false);
|
||||
expect(info.mimeType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should return correct info for PDF files', () => {
|
||||
const info = service.getFileTypeInfo('document.pdf');
|
||||
expect(info.type).toBe('pdf');
|
||||
expect(info.requiresSpecialViewer).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileExtension', () => {
|
||||
it('should extract simple extensions', () => {
|
||||
expect(service.getFileExtension('test.md')).toBe('.md');
|
||||
expect(service.getFileExtension('image.png')).toBe('.png');
|
||||
});
|
||||
|
||||
it('should handle .excalidraw.md specially', () => {
|
||||
expect(service.getFileExtension('drawing.excalidraw.md')).toBe('.excalidraw.md');
|
||||
});
|
||||
|
||||
it('should handle files without extension', () => {
|
||||
expect(service.getFileExtension('README')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle empty paths', () => {
|
||||
expect(service.getFileExtension('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasExcalidrawContent', () => {
|
||||
it('should detect compressed-json blocks', () => {
|
||||
const content = '```compressed-json\nN4KAkARALgngDgUwgLgAQQQDwMYEMA2AlgCYBOuA7hADTgQBuCpAzoQPYB2KqATLZMzYBXUtiRoIACyhQ4zZAHoFAc0JRJQgEYA6bGwC2CgF7N6hbEcK4OCtptbErHALRY8RMpWdx8Q1TdIEfARcZgRmBShcZQUebQBmbQAGGjoghH0EDihmbgBtcDBQMBLoeHF0QOwojmVg1JLIRhZ2LjQANgBWWtLm1k4AOU4xbgAWbshCDmIs\n```';
|
||||
expect(service.hasExcalidrawContent(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect excalidraw-plugin frontmatter', () => {
|
||||
const content = '---\nexcalidraw-plugin: parsed\ntags: [excalidraw]\n---';
|
||||
expect(service.hasExcalidrawContent(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for regular markdown', () => {
|
||||
const content = '# Title\n\nSome content';
|
||||
expect(service.hasExcalidrawContent(content)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty content', () => {
|
||||
expect(service.hasExcalidrawContent('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getViewerType', () => {
|
||||
it('should return excalidraw for .excalidraw.md files', () => {
|
||||
expect(service.getViewerType('test.excalidraw.md')).toBe('excalidraw');
|
||||
});
|
||||
|
||||
it('should detect excalidraw content in .md files', () => {
|
||||
const content = '```compressed-json\nN4KAkARALgngDgUwgLgAQQQDwMYEMA2AlgCYBOuA7hADTgQBuCpAzoQPYB2KqATLZMzYBXUtiRoIACyhQ4zZAHoFAc0JRJQgEYA6bGwC2CgF7N6hbEcK4OCtptbErHALRY8RMpWdx8Q1TdIEfARcZgRmBShcZQUebQBmbQAGGjoghH0EDihmbgBtcDBQMBLoeHF0QOwojmVg1JLIRhZ2LjQANgBWWtLm1k4AOU4xbgAWbshCDmIs\n```';
|
||||
expect(service.getViewerType('test.md', content)).toBe('excalidraw');
|
||||
});
|
||||
|
||||
it('should return markdown for regular .md files', () => {
|
||||
expect(service.getViewerType('test.md', '# Title')).toBe('markdown');
|
||||
});
|
||||
|
||||
it('should return image for image files', () => {
|
||||
expect(service.getViewerType('test.png')).toBe('image');
|
||||
});
|
||||
|
||||
it('should return pdf for PDF files', () => {
|
||||
expect(service.getViewerType('document.pdf')).toBe('pdf');
|
||||
});
|
||||
|
||||
it('should return text for text files', () => {
|
||||
expect(service.getViewerType('file.txt')).toBe('text');
|
||||
expect(service.getViewerType('data.json')).toBe('text');
|
||||
});
|
||||
|
||||
it('should return unknown for unsupported files', () => {
|
||||
expect(service.getViewerType('file.xyz')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
export interface FileTypeInfo {
|
||||
type: 'markdown' | 'excalidraw' | 'image' | 'pdf' | 'unknown';
|
||||
isEditable: boolean;
|
||||
requiresSpecialViewer: boolean;
|
||||
icon: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service pour détecter le type de fichier et ses caractéristiques
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class FileTypeDetectorService {
|
||||
|
||||
/**
|
||||
* Détecte si un fichier est un fichier Excalidraw
|
||||
*/
|
||||
isExcalidrawFile(path: string): boolean {
|
||||
if (!path) return false;
|
||||
const normalized = path.toLowerCase();
|
||||
return normalized.endsWith('.excalidraw.md') || normalized.endsWith('.excalidraw');
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte si un fichier est un fichier Markdown standard
|
||||
*/
|
||||
isMarkdownFile(path: string): boolean {
|
||||
if (!path) return false;
|
||||
const normalized = path.toLowerCase();
|
||||
return normalized.endsWith('.md') && !this.isExcalidrawFile(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte si un fichier est une image
|
||||
*/
|
||||
isImageFile(path: string): boolean {
|
||||
if (!path) return false;
|
||||
const normalized = path.toLowerCase();
|
||||
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico'];
|
||||
return imageExtensions.some(ext => normalized.endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte si un fichier est un PDF
|
||||
*/
|
||||
isPdfFile(path: string): boolean {
|
||||
if (!path) return false;
|
||||
return path.toLowerCase().endsWith('.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les informations complètes sur le type de fichier
|
||||
*/
|
||||
getFileTypeInfo(path: string): FileTypeInfo {
|
||||
if (this.isExcalidrawFile(path)) {
|
||||
return {
|
||||
type: 'excalidraw',
|
||||
isEditable: true,
|
||||
requiresSpecialViewer: true,
|
||||
icon: '🎨',
|
||||
mimeType: 'text/markdown'
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isMarkdownFile(path)) {
|
||||
return {
|
||||
type: 'markdown',
|
||||
isEditable: true,
|
||||
requiresSpecialViewer: false,
|
||||
icon: '📝',
|
||||
mimeType: 'text/markdown'
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isImageFile(path)) {
|
||||
const ext = path.split('.').pop()?.toLowerCase();
|
||||
return {
|
||||
type: 'image',
|
||||
isEditable: false,
|
||||
requiresSpecialViewer: false,
|
||||
icon: '🖼️',
|
||||
mimeType: `image/${ext === 'svg' ? 'svg+xml' : ext}`
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isPdfFile(path)) {
|
||||
return {
|
||||
type: 'pdf',
|
||||
isEditable: false,
|
||||
requiresSpecialViewer: true,
|
||||
icon: '📄',
|
||||
mimeType: 'application/pdf'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'unknown',
|
||||
isEditable: false,
|
||||
requiresSpecialViewer: false,
|
||||
icon: '📎'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait l'extension du fichier
|
||||
*/
|
||||
getFileExtension(path: string): string {
|
||||
if (!path) return '';
|
||||
|
||||
// Handle .excalidraw.md specially
|
||||
if (path.toLowerCase().endsWith('.excalidraw.md')) {
|
||||
return '.excalidraw.md';
|
||||
}
|
||||
|
||||
const parts = path.split('.');
|
||||
return parts.length > 1 ? `.${parts[parts.length - 1]}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le nom du fichier sans extension
|
||||
*/
|
||||
getFileNameWithoutExtension(path: string): string {
|
||||
if (!path) return '';
|
||||
|
||||
const fileName = path.split('/').pop() || path.split('\\').pop() || path;
|
||||
const ext = this.getFileExtension(path);
|
||||
|
||||
return ext ? fileName.slice(0, -ext.length) : fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si le contenu d'un fichier markdown contient du JSON Excalidraw
|
||||
*/
|
||||
hasExcalidrawContent(content: string): boolean {
|
||||
if (!content) return false;
|
||||
|
||||
// Check for compressed-json block (Obsidian format)
|
||||
if (/```\s*compressed-json\s*\n/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for excalidraw-plugin frontmatter
|
||||
if (/excalidraw-plugin:\s*parsed/i.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine le viewer approprié pour un fichier
|
||||
*/
|
||||
getViewerType(path: string, content?: string): 'markdown' | 'excalidraw' | 'image' | 'pdf' | 'text' | 'unknown' {
|
||||
// Check file extension first
|
||||
if (this.isExcalidrawFile(path)) {
|
||||
return 'excalidraw';
|
||||
}
|
||||
|
||||
// If content is provided and it's a .md file, check content
|
||||
if (content && this.isMarkdownFile(path)) {
|
||||
if (this.hasExcalidrawContent(content)) {
|
||||
return 'excalidraw';
|
||||
}
|
||||
return 'markdown';
|
||||
}
|
||||
|
||||
if (this.isMarkdownFile(path)) {
|
||||
return 'markdown';
|
||||
}
|
||||
|
||||
if (this.isImageFile(path)) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (this.isPdfFile(path)) {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
// Check if it's a text file
|
||||
const textExtensions = ['.txt', '.json', '.xml', '.csv', '.log', '.yaml', '.yml', '.toml'];
|
||||
if (textExtensions.some(ext => path.toLowerCase().endsWith(ext))) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
+232
-144
@@ -8,6 +8,45 @@ import markdownItFootnote from 'markdown-it-footnote';
|
||||
import markdownItMultimdTable from 'markdown-it-multimd-table';
|
||||
import { Note } from '../types';
|
||||
|
||||
interface TransformContext {
|
||||
tag: string;
|
||||
attrs: string;
|
||||
inner: string;
|
||||
original: string;
|
||||
}
|
||||
|
||||
type TransformRule = (context: TransformContext) => string | null;
|
||||
|
||||
class SimpleLruCache<K, V> {
|
||||
private readonly limit: number;
|
||||
private readonly map = new Map<K, V>();
|
||||
|
||||
constructor(limit: number) {
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
const value = this.map.get(key);
|
||||
if (value === undefined) return undefined;
|
||||
this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
if (this.map.has(key)) this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
if (this.map.size > this.limit) {
|
||||
const firstKey = this.map.keys().next().value;
|
||||
this.map.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
return this.map.has(key);
|
||||
}
|
||||
}
|
||||
|
||||
interface MarkdownRenderEnv {
|
||||
codeBlockIndex: number;
|
||||
}
|
||||
@@ -50,44 +89,57 @@ export class MarkdownService {
|
||||
private readonly tagPaletteSize = 12;
|
||||
private tagColorCache = new Map<string, number>();
|
||||
|
||||
private md: any;
|
||||
private activeSlugState: Map<string, number> = new Map();
|
||||
|
||||
private static readonly INLINE_TAG_RE = /(^|[\s(>)])#([^\s#.,;:!?"'(){}\[\]<>]+)/g;
|
||||
private static readonly FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*(\n|$)/;
|
||||
private static readonly WINDOWS_NL_RE = /\r\n/g;
|
||||
private static readonly WIKILINK_RE = /(?<!\!)\[\[([^\]]+)\]\]/g;
|
||||
private static readonly CODE_BLOCK_RE = /```[\s\S]*?```/g;
|
||||
private static readonly INLINE_CODE_RE = /`[^`]*`/g;
|
||||
private static readonly MATH_BLOCK_DOLLARS_RE = /(^|[^\\])\$\$([\s\S]+?)\$\$/g;
|
||||
private static readonly MATH_BLOCK_BRACKETS_RE = /\\\[(.+?)\\\]/g;
|
||||
private static readonly MATH_INLINE_DOLLAR_RE = /(?<!\\)\$(?!\s)([^$]+?)(?<!\\)\$(?!\d)/g;
|
||||
private static readonly MATH_INLINE_PAREN_RE = /\\\((.+?)\\\)/g;
|
||||
private static readonly ATTACH_EMBED_RE = /!\[\[(.*?)\]\]/g;
|
||||
|
||||
private static readonly FAST_LIMIT = 10 * 1024; // 10KB
|
||||
private static readonly FAST_PATTERNS: RegExp[] = [
|
||||
/!\[\[/, // embeds
|
||||
/\[\[/, // wiki links
|
||||
/\[!/, // callouts
|
||||
/\$\$/, // math block
|
||||
/\\\(/, // math inline
|
||||
/\\\[/, // math block alt
|
||||
];
|
||||
|
||||
private static readonly HL_CACHE = new SimpleLruCache<string, string>(500);
|
||||
|
||||
constructor() {
|
||||
this.md = this.createMarkdownIt();
|
||||
}
|
||||
|
||||
render(markdown: string, allNotes: Note[], currentNote?: Note): string {
|
||||
if (!markdown) return '';
|
||||
|
||||
if (this.canUseFastPath(markdown)) {
|
||||
const env: MarkdownRenderEnv = { codeBlockIndex: 0 };
|
||||
this.activeSlugState = new Map<string, number>();
|
||||
return this.md.render(markdown, env);
|
||||
}
|
||||
|
||||
const env: MarkdownRenderEnv = { codeBlockIndex: 0 };
|
||||
const headingSlugState = new Map<string, number>();
|
||||
const markdownIt = this.createMarkdownIt(env, headingSlugState);
|
||||
this.activeSlugState = new Map<string, number>();
|
||||
|
||||
const preprocessing = this.preprocessMarkdown(markdown);
|
||||
const decorated = this.decorateInlineTags(preprocessing.markdown);
|
||||
let html = markdownIt.render(decorated, env);
|
||||
let html = this.md.render(decorated, env);
|
||||
|
||||
html = this.restoreWikiLinks(html, preprocessing.wikiLinks);
|
||||
html = this.restoreMath(html, preprocessing.math);
|
||||
html = this.transformCallouts(html);
|
||||
html = this.transformTaskLists(html);
|
||||
html = this.wrapTables(html);
|
||||
|
||||
// Embedded Files ![[file.png]] using Attachements-Path from HOME.md (traité avant les liens internes)
|
||||
const homeNote = allNotes.find(n =>
|
||||
(n.fileName?.toLowerCase?.() === 'home.md') ||
|
||||
(n.originalPath?.toLowerCase?.() === 'home') ||
|
||||
(n.originalPath?.toLowerCase?.().endsWith('/home'))
|
||||
);
|
||||
const attachmentsBase = (() => {
|
||||
if (!homeNote) return '';
|
||||
const fm = homeNote.frontmatter || {};
|
||||
const key = Object.keys(fm).find(k => k?.toLowerCase?.() === 'attachements-path' || k?.toLowerCase?.() === 'attachments-path');
|
||||
const raw = key ? `${fm[key] ?? ''}` : '';
|
||||
return raw;
|
||||
})();
|
||||
html = html.replace(/!\[\[(.*?)\]\]/g, (match, rawName) => {
|
||||
const filename = `${rawName}`.trim();
|
||||
const safeAlt = this.escapeHtml(filename);
|
||||
const notePath = (currentNote?.filePath || currentNote?.originalPath || '').replace(/\\/g, '/');
|
||||
const src = `/api/attachments/resolve?name=${encodeURIComponent(filename)}¬e=${encodeURIComponent(notePath)}&base=${encodeURIComponent(attachmentsBase)}`;
|
||||
return `<figure class="my-4 md-attachment-figure">
|
||||
<img src="${src}" alt="${safeAlt}" loading="lazy" class="rounded-lg max-w-full h-auto mx-auto md-attachment-image" data-attachment-name="${safeAlt}" data-error-message="<div class='missing-attachment text-center text-sm text-red-500 dark:text-red-400'>Attachement ${this.escapeHtml(filename).replace(/'/g, ''')} introuvable</div>">
|
||||
<figcaption class="text-center text-sm text-text-muted">${safeAlt}</figcaption>
|
||||
</figure>`;
|
||||
});
|
||||
html = this.applyHtmlTransforms(html);
|
||||
html = this.embedAttachments(html, allNotes, currentNote);
|
||||
|
||||
return html;
|
||||
}
|
||||
@@ -118,8 +170,7 @@ export class MarkdownService {
|
||||
}
|
||||
|
||||
private decorateInlineTags(markdown: string): string {
|
||||
const tagRegex = /(^|[\s(>)])#([^\s#.,;:!?"'(){}\[\]<>]+)/g;
|
||||
return markdown.replace(tagRegex, (match, prefix, tag) => {
|
||||
return markdown.replace(MarkdownService.INLINE_TAG_RE, (match, prefix, tag) => {
|
||||
if (!tag) {
|
||||
return match;
|
||||
}
|
||||
@@ -146,7 +197,8 @@ export class MarkdownService {
|
||||
this.tagColorCache.set(normalized, index);
|
||||
return `md-tag-color-${index}`;
|
||||
}
|
||||
private createMarkdownIt(env: MarkdownRenderEnv, slugState: Map<string, number>) {
|
||||
|
||||
private createMarkdownIt() {
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: false,
|
||||
@@ -161,7 +213,7 @@ export class MarkdownService {
|
||||
});
|
||||
|
||||
md.use(markdownItAnchor, {
|
||||
slugify: (str) => this.slugify(str, slugState),
|
||||
slugify: (str) => this.slugify(str, this.activeSlugState),
|
||||
tabIndex: false
|
||||
});
|
||||
|
||||
@@ -324,25 +376,29 @@ export class MarkdownService {
|
||||
}
|
||||
|
||||
private highlightCode(code: string, normalizedLanguage: string, rawLanguage: string): string {
|
||||
const key = `${normalizedLanguage}\u0000${code}`;
|
||||
const cached = MarkdownService.HL_CACHE.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
let out: string;
|
||||
if (normalizedLanguage && hljs.getLanguage(normalizedLanguage)) {
|
||||
return hljs.highlight(code, { language: normalizedLanguage }).value;
|
||||
out = hljs.highlight(code, { language: normalizedLanguage }).value;
|
||||
} else if (rawLanguage && !hljs.getLanguage(normalizedLanguage)) {
|
||||
out = this.escapeHtml(code);
|
||||
} else {
|
||||
out = hljs.highlightAuto(code).value;
|
||||
}
|
||||
|
||||
if (rawLanguage && !hljs.getLanguage(normalizedLanguage)) {
|
||||
return this.escapeHtml(code);
|
||||
}
|
||||
|
||||
return hljs.highlightAuto(code).value;
|
||||
MarkdownService.HL_CACHE.set(key, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
private preprocessMarkdown(input: string): PreprocessResult {
|
||||
const wikiLinks: WikiLinkPlaceholder[] = [];
|
||||
const math: MathPlaceholder[] = [];
|
||||
|
||||
let text = this.stripFrontmatter(input.replace(/\r\n/g, '\n'));
|
||||
let text = this.stripFrontmatter(input.replace(MarkdownService.WINDOWS_NL_RE, '\n'));
|
||||
|
||||
const wikiRegex = /(?<!\!)\[\[([^\]]+)\]\]/g;
|
||||
text = text.replace(wikiRegex, (_match, inner) => {
|
||||
text = text.replace(MarkdownService.WIKILINK_RE, (_match, inner) => {
|
||||
const placeholder = `@@WIKILINK::${wikiLinks.length}@@`;
|
||||
const [targetPartRaw, aliasRaw] = `${inner}`.split('|');
|
||||
let targetPart = targetPartRaw ?? '';
|
||||
@@ -395,8 +451,8 @@ export class MarkdownService {
|
||||
});
|
||||
};
|
||||
|
||||
text = stashSegments(text, /```[\s\S]*?```/g, codeBlockPlaceholders, 'CODE_BLOCK');
|
||||
text = stashSegments(text, /`[^`]*`/g, inlineCodePlaceholders, 'CODE_INLINE');
|
||||
text = stashSegments(text, MarkdownService.CODE_BLOCK_RE, codeBlockPlaceholders, 'CODE_BLOCK');
|
||||
text = stashSegments(text, MarkdownService.INLINE_CODE_RE, inlineCodePlaceholders, 'CODE_INLINE');
|
||||
|
||||
const addMathPlaceholder = (expression: string, display: 'block' | 'inline') => {
|
||||
const placeholder = `@@MATH::${display.toUpperCase()}::${math.length}@@`;
|
||||
@@ -404,22 +460,22 @@ export class MarkdownService {
|
||||
return placeholder;
|
||||
};
|
||||
|
||||
text = text.replace(/(^|[^\\])\$\$([\s\S]+?)\$\$/g, (match, prefix, expr) => {
|
||||
text = text.replace(MarkdownService.MATH_BLOCK_DOLLARS_RE, (match, prefix, expr) => {
|
||||
const placeholder = addMathPlaceholder(expr, 'block');
|
||||
return `${prefix}${placeholder}`;
|
||||
});
|
||||
|
||||
text = text.replace(/\\\[(.+?)\\\]/g, (_match, expr) => addMathPlaceholder(expr, 'block'));
|
||||
text = text.replace(MarkdownService.MATH_BLOCK_BRACKETS_RE, (_match, expr) => addMathPlaceholder(expr, 'block'));
|
||||
|
||||
text = text.replace(/(?<!\\)\$(?!\s)([^$]+?)(?<!\\)\$(?!\d)/g, (_match, expr) => addMathPlaceholder(expr, 'inline'));
|
||||
text = text.replace(/\\\((.+?)\\\)/g, (_match, expr) => addMathPlaceholder(expr, 'inline'));
|
||||
text = text.replace(MarkdownService.MATH_INLINE_DOLLAR_RE, (_match, expr) => addMathPlaceholder(expr, 'inline'));
|
||||
text = text.replace(MarkdownService.MATH_INLINE_PAREN_RE, (_match, expr) => addMathPlaceholder(expr, 'inline'));
|
||||
|
||||
for (const { placeholder, content } of inlineCodePlaceholders) {
|
||||
text = text.split(placeholder).join(content);
|
||||
text = text.replaceAll(placeholder, content);
|
||||
}
|
||||
|
||||
for (const { placeholder, content } of codeBlockPlaceholders) {
|
||||
text = text.split(placeholder).join(content);
|
||||
text = text.replaceAll(placeholder, content);
|
||||
}
|
||||
|
||||
return { markdown: text, wikiLinks, math };
|
||||
@@ -430,7 +486,6 @@ export class MarkdownService {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Build once all replacements and apply for both current and legacy placeholder forms
|
||||
let out = html;
|
||||
for (let i = 0; i < links.length; i++) {
|
||||
const link = links[i];
|
||||
@@ -449,20 +504,17 @@ export class MarkdownService {
|
||||
}
|
||||
const replacement = `<a ${attrs.join(' ')}>${this.escapeHtml(link.alias)}</a>`;
|
||||
|
||||
// Replace the current placeholder exactly
|
||||
if (link.placeholder) {
|
||||
out = out.split(link.placeholder).join(replacement);
|
||||
out = out.replaceAll(link.placeholder, replacement);
|
||||
}
|
||||
|
||||
// Replace legacy placeholders that may exist from older renders:
|
||||
// @@WIKILINK_0@@ or @@__WIKILINK_0__@@
|
||||
const legacySimple = `@@WIKILINK_${i}@@`;
|
||||
const legacyUnderscore = `@@__WIKILINK_${i}__@@`;
|
||||
if (out.includes(legacySimple)) {
|
||||
out = out.split(legacySimple).join(replacement);
|
||||
out = out.replaceAll(legacySimple, replacement);
|
||||
}
|
||||
if (out.includes(legacyUnderscore)) {
|
||||
out = out.split(legacyUnderscore).join(replacement);
|
||||
out = out.replaceAll(legacyUnderscore, replacement);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -479,7 +531,7 @@ export class MarkdownService {
|
||||
const replacement = item.display === 'block'
|
||||
? `<div class="md-math-block" data-math="${dataAttr}">${safeExpr}</div>`
|
||||
: `<span class="md-math-inline" data-math="${dataAttr}">${safeExpr}</span>`;
|
||||
return acc.split(item.placeholder).join(replacement);
|
||||
return acc.replaceAll(item.placeholder, replacement);
|
||||
}, html);
|
||||
}
|
||||
|
||||
@@ -488,7 +540,7 @@ export class MarkdownService {
|
||||
return markdown;
|
||||
}
|
||||
|
||||
const match = markdown.match(/^---\s*\n([\s\S]*?)\n---\s*(\n|$)/);
|
||||
const match = markdown.match(MarkdownService.FRONTMATTER_RE);
|
||||
if (!match) {
|
||||
return markdown;
|
||||
}
|
||||
@@ -496,96 +548,132 @@ export class MarkdownService {
|
||||
return markdown.slice(match[0].length);
|
||||
}
|
||||
|
||||
private transformCallouts(html: string): string {
|
||||
return html.replace(/<blockquote([\s\S]*?)>([\s\S]*?)<\/blockquote>/g, (match, _attrs, inner) => {
|
||||
const firstParagraphMatch = inner.match(/^\s*<p>([\s\S]*?)<\/p>([\s\S]*)$/);
|
||||
if (!firstParagraphMatch) {
|
||||
return match;
|
||||
private transformBlockquoteToCallout(attrs: string, inner: string, original: string): string | null {
|
||||
const firstParagraphMatch = inner.match(/^\s*<p>([\s\S]*?)<\/p>([\s\S]*)$/);
|
||||
if (!firstParagraphMatch) return null;
|
||||
const markerMatch = firstParagraphMatch[1].match(/^\s*\[!(\w+)\]\s*([\s\S]*)$/);
|
||||
if (!markerMatch) return null;
|
||||
const type = markerMatch[1].toUpperCase();
|
||||
const calloutClass = this.getCalloutClass(type);
|
||||
const title = this.formatCalloutTitle(type);
|
||||
const remainingFirstParagraph = markerMatch[2].trim();
|
||||
const restContent = firstParagraphMatch[2].trim();
|
||||
const bodySegments: string[] = [];
|
||||
if (remainingFirstParagraph) bodySegments.push(`<p>${remainingFirstParagraph}</p>`);
|
||||
if (restContent) bodySegments.push(restContent);
|
||||
const bodyHtml = bodySegments.join('');
|
||||
return `<div class="${calloutClass} text-left" style="max-width: 800px; width: 100%; margin: 0;" data-callout-type="${type.toLowerCase()}"><div class="callout__title">${title}</div><div class="callout__body">${bodyHtml}</div></div>`;
|
||||
}
|
||||
|
||||
private transformUlTaskList(attrs: string, inner: string, original: string): string | null {
|
||||
const m = attrs.match(/class="([^"]*?)"/i);
|
||||
const classes = m ? m[1].split(/\s+/).filter(Boolean) : [];
|
||||
if (!classes.some(c => c.includes('task-list'))) return null;
|
||||
const filtered = classes.filter(cls => !['task-list', 'list-disc', 'list-decimal', 'ml-6'].includes(cls));
|
||||
const classList = Array.from(new Set([...filtered, 'md-task-list', 'mb-4']));
|
||||
return `<ul class="${classList.join(' ')}">${inner}</ul>`;
|
||||
}
|
||||
|
||||
private transformLiTaskItem(attrs: string, inner: string, original: string): string | null {
|
||||
const m = attrs.match(/class="([^"]*?)"/i);
|
||||
const classes = m ? m[1].split(/\s+/).filter(Boolean) : [];
|
||||
if (!classes.some(c => c.includes('task-list-item'))) return null;
|
||||
|
||||
const isChecked = /<input[^>]*\schecked[^>]*>/i.test(inner);
|
||||
const baseClasses = ['md-task-item', 'text-text-main'];
|
||||
if (isChecked) baseClasses.push('md-task-item--done');
|
||||
|
||||
const trimmedInner = inner.trim();
|
||||
const inputMatch = trimmedInner.match(/^<input[^>]*>/i);
|
||||
if (!inputMatch) return null;
|
||||
|
||||
const remaining = trimmedInner.slice(inputMatch[0].length).trim();
|
||||
let primaryContent = remaining;
|
||||
let trailingContent = '';
|
||||
|
||||
const paragraphMatch = remaining.match(/^<p>([\s\S]*?)<\/p>([\s\S]*)$/);
|
||||
if (paragraphMatch) {
|
||||
primaryContent = paragraphMatch[1].trim();
|
||||
trailingContent = paragraphMatch[2].trim();
|
||||
} else if (remaining.includes('<')) {
|
||||
const firstTagIndex = remaining.indexOf('<');
|
||||
if (firstTagIndex > 0) {
|
||||
primaryContent = remaining.slice(0, firstTagIndex).trim();
|
||||
trailingContent = remaining.slice(firstTagIndex).trim();
|
||||
}
|
||||
}
|
||||
|
||||
const markerMatch = firstParagraphMatch[1].match(/^\s*\[!(\w+)\]\s*([\s\S]*)$/);
|
||||
if (!markerMatch) {
|
||||
return match;
|
||||
const checkboxState = isChecked ? 'checked' : '';
|
||||
const ariaChecked = isChecked ? 'true' : 'false';
|
||||
const checkbox = `<input type="checkbox" class="md-task-checkbox" ${checkboxState} disabled aria-checked="${ariaChecked}" tabindex="-1">`;
|
||||
const labelText = primaryContent || '';
|
||||
const label = `<label class="md-task">${checkbox}<span class="md-task-label-text">${labelText}</span></label>`;
|
||||
|
||||
const extra = classes.filter(cls => cls !== 'task-list-item');
|
||||
const classList = Array.from(new Set([...baseClasses, ...extra]));
|
||||
const trailing = trailingContent ? trailingContent : '';
|
||||
return `<li class="${classList.join(' ')}">${label}${trailing}</li>`;
|
||||
}
|
||||
|
||||
private transformTableWrap(attrs: string, inner: string, original: string): string | null {
|
||||
return `<div class="markdown-table"><table${attrs}>${inner}</table></div>`;
|
||||
}
|
||||
|
||||
private applyHtmlTransforms(html: string): string {
|
||||
const re = /<(\w+)([^>]*)>([\s\S]*?)<\/\1>/g;
|
||||
let out = '';
|
||||
let lastIdx = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(html))) {
|
||||
out += html.slice(lastIdx, m.index);
|
||||
const [full, tag, attrs, inner] = m;
|
||||
let replaced: string | null = null;
|
||||
if (tag === 'blockquote') {
|
||||
replaced = this.transformBlockquoteToCallout(attrs, inner, full);
|
||||
} else if (tag === 'ul') {
|
||||
replaced = this.transformUlTaskList(attrs, inner, full);
|
||||
} else if (tag === 'li') {
|
||||
replaced = this.transformLiTaskItem(attrs, inner, full);
|
||||
} else if (tag === 'table') {
|
||||
replaced = this.transformTableWrap(attrs, inner, full);
|
||||
}
|
||||
out += replaced ?? full;
|
||||
lastIdx = re.lastIndex;
|
||||
}
|
||||
return out + html.slice(lastIdx);
|
||||
}
|
||||
|
||||
const type = markerMatch[1].toUpperCase();
|
||||
const calloutClass = this.getCalloutClass(type);
|
||||
const title = this.formatCalloutTitle(type);
|
||||
const remainingFirstParagraph = markerMatch[2].trim();
|
||||
const restContent = firstParagraphMatch[2].trim();
|
||||
|
||||
const bodySegments: string[] = [];
|
||||
if (remainingFirstParagraph) {
|
||||
bodySegments.push(`<p>${remainingFirstParagraph}</p>`);
|
||||
}
|
||||
if (restContent) {
|
||||
bodySegments.push(restContent);
|
||||
}
|
||||
|
||||
const bodyHtml = bodySegments.join('');
|
||||
|
||||
return `<div class="${calloutClass} text-left" style="max-width: 800px; width: 100%; margin: 0;" data-callout-type="${type.toLowerCase()}"><div class="callout__title">${title}</div><div class="callout__body">${bodyHtml}</div></div>`;
|
||||
private embedAttachments(html: string, allNotes: Note[], currentNote?: Note): string {
|
||||
const homeNote = allNotes.find(n =>
|
||||
(n.fileName?.toLowerCase?.() === 'home.md') ||
|
||||
(n.originalPath?.toLowerCase?.() === 'home') ||
|
||||
(n.originalPath?.toLowerCase?.().endsWith('/home'))
|
||||
);
|
||||
const attachmentsBase = (() => {
|
||||
if (!homeNote) return '';
|
||||
const fm = homeNote.frontmatter || {};
|
||||
const key = Object.keys(fm).find(k => k?.toLowerCase?.() === 'attachements-path' || k?.toLowerCase?.() === 'attachments-path');
|
||||
const raw = key ? `${fm[key] ?? ''}` : '';
|
||||
return raw;
|
||||
})();
|
||||
return html.replace(MarkdownService.ATTACH_EMBED_RE, (_match, rawName) => {
|
||||
const filename = `${rawName}`.trim();
|
||||
const safeAlt = this.escapeHtml(filename);
|
||||
const notePath = (currentNote?.filePath || currentNote?.originalPath || '').replace(/\\/g, '/');
|
||||
const src = `/api/attachments/resolve?name=${encodeURIComponent(filename)}¬e=${encodeURIComponent(notePath)}&base=${encodeURIComponent(attachmentsBase)}`;
|
||||
return `<figure class="my-4 md-attachment-figure">
|
||||
<img src="${src}" alt="${safeAlt}" loading="lazy" class="rounded-lg max-w-full h-auto mx-auto md-attachment-image" data-attachment-name="${safeAlt}" data-error-message="<div class='missing-attachment text-center text-sm text-red-500 dark:text-red-400'>Attachement ${this.escapeHtml(filename).replace(/'/g, ''')} introuvable</div>">
|
||||
<figcaption class="text-center text-sm text-text-muted">${safeAlt}</figcaption>
|
||||
</figure>`;
|
||||
});
|
||||
}
|
||||
|
||||
private transformTaskLists(html: string): string {
|
||||
const listRegex = /<ul class="([^"]*?task-list[^"]*?)">/g;
|
||||
html = html.replace(listRegex, (_match, classAttr) => {
|
||||
const classes = classAttr.split(/\s+/).filter(Boolean).filter(cls => !['task-list', 'list-disc', 'list-decimal', 'ml-6'].includes(cls));
|
||||
const classList = Array.from(new Set([...classes, 'md-task-list', 'mb-4']));
|
||||
return `<ul class="${classList.join(' ')}">`;
|
||||
});
|
||||
|
||||
const itemRegex = /<li class="task-list-item([^"]*)">([\s\S]*?)<\/li>/g;
|
||||
return html.replace(itemRegex, (_match, extraClasses, inner) => {
|
||||
const isChecked = /<input[^>]*\schecked[^>]*>/i.test(inner);
|
||||
const classes = ['md-task-item', 'text-text-main'];
|
||||
if (isChecked) {
|
||||
classes.push('md-task-item--done');
|
||||
}
|
||||
|
||||
const trimmedInner = inner.trim();
|
||||
const inputMatch = trimmedInner.match(/^<input[^>]*>/i);
|
||||
if (!inputMatch) {
|
||||
return _match;
|
||||
}
|
||||
|
||||
const remaining = trimmedInner.slice(inputMatch[0].length).trim();
|
||||
let primaryContent = remaining;
|
||||
let trailingContent = '';
|
||||
|
||||
const paragraphMatch = remaining.match(/^<p>([\s\S]*?)<\/p>([\s\S]*)$/);
|
||||
if (paragraphMatch) {
|
||||
primaryContent = paragraphMatch[1].trim();
|
||||
trailingContent = paragraphMatch[2].trim();
|
||||
}
|
||||
|
||||
if (!paragraphMatch && remaining.includes('<')) {
|
||||
const firstTagIndex = remaining.indexOf('<');
|
||||
if (firstTagIndex > 0) {
|
||||
primaryContent = remaining.slice(0, firstTagIndex).trim();
|
||||
trailingContent = remaining.slice(firstTagIndex).trim();
|
||||
}
|
||||
}
|
||||
|
||||
const checkboxState = isChecked ? 'checked' : '';
|
||||
const ariaChecked = isChecked ? 'true' : 'false';
|
||||
const checkbox = `<input type="checkbox" class="md-task-checkbox" ${checkboxState} disabled aria-checked="${ariaChecked}" tabindex="-1">`;
|
||||
const labelText = primaryContent || '';
|
||||
const label = `<label class="md-task">${checkbox}<span class="md-task-label-text">${labelText}</span></label>`;
|
||||
|
||||
const extra = extraClasses.split(/\s+/).filter(Boolean).filter(cls => cls !== 'task-list-item');
|
||||
const classList = Array.from(new Set([...classes, ...extra]));
|
||||
|
||||
const trailing = trailingContent ? trailingContent : '';
|
||||
return `<li class="${classList.join(' ')}">${label}${trailing}</li>`;
|
||||
});
|
||||
}
|
||||
|
||||
private wrapTables(html: string): string {
|
||||
return html.replace(/<table([\s\S]*?)>([\s\S]*?)<\/table>/g, (_match, attrs, inner) => {
|
||||
return `<div class="markdown-table"><table${attrs}>${inner}</table></div>`;
|
||||
});
|
||||
private canUseFastPath(markdown: string): boolean {
|
||||
if (markdown.length > MarkdownService.FAST_LIMIT) return false;
|
||||
for (const re of MarkdownService.FAST_PATTERNS) {
|
||||
if (re.test(markdown)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private escapeHtml(unsafe: string): string {
|
||||
|
||||
Reference in New Issue
Block a user