feat: enhance code viewer with modern Angular inputs and improved styling
- Updated CodeRendererComponent to use Angular's new input() signals instead of @Input decorators - Added fallback content escaping for code that can't be syntax highlighted - Improved code viewer styling with larger fonts, better padding and gradient header background - Enhanced file content fetching with robust cache-busting and fallback mechanisms - Added debug logging to track content loading and syntax highlighting - Fixe
This commit is contained in:
		
							parent
							
								
									0dc346d6b7
								
							
						
					
					
						commit
						79e80fd798
					
				@ -31,7 +31,7 @@ Tu es un pair-programmer Angular 20. Objectif: retirer tout import global d'Exca
 | 
				
			|||||||
5) Ne change pas l’UI/UX. Respecte Angular 20 et Tailwind 3.4.
 | 
					5) Ne change pas l’UI/UX. Respecte Angular 20 et Tailwind 3.4.
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### 0.2 Virtualiser la liste centrale Nimbus avec `PaginatedNotesList`
 | 
					### ✅ 0.2 Virtualiser la liste centrale Nimbus avec `PaginatedNotesList`
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
- Objectif: maintenir ≥55 FPS et mémoire <200MB avec 5k+ notes.
 | 
					- Objectif: maintenir ≥55 FPS et mémoire <200MB avec 5k+ notes.
 | 
				
			||||||
- Critères d’acceptation:
 | 
					- Critères d’acceptation:
 | 
				
			||||||
 | 
				
			|||||||
@ -1,4 +1,4 @@
 | 
				
			|||||||
import { Component, Input, computed } from '@angular/core';
 | 
					import { Component, computed, input, effect } from '@angular/core';
 | 
				
			||||||
import { CommonModule } from '@angular/common';
 | 
					import { CommonModule } from '@angular/common';
 | 
				
			||||||
import hljs from 'highlight.js/lib/core';
 | 
					import hljs from 'highlight.js/lib/core';
 | 
				
			||||||
import javascript from 'highlight.js/lib/languages/javascript';
 | 
					import javascript from 'highlight.js/lib/languages/javascript';
 | 
				
			||||||
@ -32,16 +32,16 @@ hljs.registerLanguage('sql', sql);
 | 
				
			|||||||
  styles: [`
 | 
					  styles: [`
 | 
				
			||||||
    :host { display:block; }
 | 
					    :host { display:block; }
 | 
				
			||||||
    .root { border: 1px solid var(--border); background: var(--card); border-radius: 1rem; overflow: hidden; }
 | 
					    .root { border: 1px solid var(--border); background: var(--card); border-radius: 1rem; overflow: hidden; }
 | 
				
			||||||
    .header { display:flex; align-items:center; gap:.5rem; padding:.5rem .75rem; font-size:.75rem; color: var(--text-muted); border-bottom: 1px solid var(--border); background: color-mix(in oklab, var(--card) 92%, black 8%); }
 | 
					    .header { display:flex; align-items:center; gap:.5rem; padding:.6rem .9rem; font-size:.8rem; color: var(--text-muted); border-bottom: 1px solid var(--border); background: linear-gradient(90deg, color-mix(in oklab, var(--card) 92%, black 8%), color-mix(in oklab, var(--card) 86%, black 14%)); }
 | 
				
			||||||
    .title { display:inline-flex; align-items:center; gap:.5rem; font-weight:600; color: var(--text-main); }
 | 
					    .title { display:inline-flex; align-items:center; gap:.5rem; font-weight:600; color: var(--text-main); }
 | 
				
			||||||
    .title svg { width: 16px; height: 16px; color: var(--accent, #9b87f5); }
 | 
					    .title svg { width: 16px; height: 16px; color: var(--accent, #9b87f5); }
 | 
				
			||||||
    .lang { margin-left:auto; padding:.125rem .5rem; border:1px solid var(--border); border-radius:.5rem; font-weight:600; color: var(--text-main); }
 | 
					    .lang { margin-left:auto; padding:.15rem .6rem; border:1px solid var(--border); border-radius:.5rem; font-weight:700; letter-spacing:.04em; color: var(--text-main); }
 | 
				
			||||||
    pre { margin:0; padding: .75rem 1rem; font-family: var(--font-mono, ui-monospace, Menlo, Monaco, Consolas, 'Courier New', monospace); font-size:.875rem; line-height:1.5; overflow:auto; }
 | 
					    pre { margin:0; padding: .9rem 1.1rem; font-family: var(--font-mono, ui-monospace, Menlo, Monaco, Consolas, 'Courier New', monospace); font-size:.9rem; line-height:1.5; overflow:auto; }
 | 
				
			||||||
    code { display:block; }
 | 
					    code { display:block; }
 | 
				
			||||||
    .hljs { color: var(--text-main); }
 | 
					    .hljs { color: var(--text-main); }
 | 
				
			||||||
  `],
 | 
					  `],
 | 
				
			||||||
  template: `
 | 
					  template: `
 | 
				
			||||||
    <div class="root animate-fadeIn">
 | 
					    <div class="root md-view animate-fadeIn">
 | 
				
			||||||
      <div class="header">
 | 
					      <div class="header">
 | 
				
			||||||
        <span class="title">
 | 
					        <span class="title">
 | 
				
			||||||
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
 | 
					          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
 | 
				
			||||||
@ -57,16 +57,16 @@ hljs.registerLanguage('sql', sql);
 | 
				
			|||||||
  `
 | 
					  `
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
export class CodeRendererComponent {
 | 
					export class CodeRendererComponent {
 | 
				
			||||||
  @Input() path: string = '';
 | 
					  path = input<string>('');
 | 
				
			||||||
  @Input() content: string = '';
 | 
					  content = input<string>('');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  fileName = computed(() => {
 | 
					  fileName = computed(() => {
 | 
				
			||||||
    const p = this.path || '';
 | 
					    const p = this.path() || '';
 | 
				
			||||||
    return p.split('/').pop() || p.split('\\').pop() || 'code';
 | 
					    return p.split('/').pop() || p.split('\\').pop() || 'code';
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  languageLabel = computed(() => {
 | 
					  languageLabel = computed(() => {
 | 
				
			||||||
    const src = this.content || '';
 | 
					    const src = this.content() || '';
 | 
				
			||||||
    if (!src.trim()) return 'TEXT';
 | 
					    if (!src.trim()) return 'TEXT';
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const res = hljs.highlightAuto(src);
 | 
					      const res = hljs.highlightAuto(src);
 | 
				
			||||||
@ -77,13 +77,29 @@ export class CodeRendererComponent {
 | 
				
			|||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  highlighted = computed(() => {
 | 
					  highlighted = computed(() => {
 | 
				
			||||||
    const src = this.content || '';
 | 
					    const src = this.content() || '';
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      if (!src.trim()) { return hljs.highlightAuto('').value; }
 | 
					      if (!src.trim()) { return ''; }
 | 
				
			||||||
      const res = hljs.highlightAuto(src);
 | 
					      const res = hljs.highlightAuto(src);
 | 
				
			||||||
      return res.value;
 | 
					      const v = res.value;
 | 
				
			||||||
 | 
					      return v && v.length > 0 ? v : this.escapeHtml(src);
 | 
				
			||||||
    } catch {
 | 
					    } catch {
 | 
				
			||||||
      return hljs.highlightAuto(src).value;
 | 
					      return this.escapeHtml(src);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  constructor() {
 | 
				
			||||||
 | 
					    effect(() => {
 | 
				
			||||||
 | 
					      const len = (this.content() || '').length;
 | 
				
			||||||
 | 
					      // eslint-disable-next-line no-console
 | 
				
			||||||
 | 
					      console.log('[CodeRenderer] content length:', len);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private escapeHtml(src: string): string {
 | 
				
			||||||
 | 
					    return src
 | 
				
			||||||
 | 
					      .replace(/&/g, '&')
 | 
				
			||||||
 | 
					      .replace(/</g, '<')
 | 
				
			||||||
 | 
					      .replace(/>/g, '>');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -72,7 +72,7 @@ import { VideoPlayerComponent } from '../../app/features/note-view/components/vi
 | 
				
			|||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <!-- Code Viewer -->
 | 
					      <!-- Code Viewer -->
 | 
				
			||||||
      <div *ngIf="viewerType() === 'code'" class="smart-file-viewer__text animate-fadeIn w-full h-full">
 | 
					      <div *ngIf="viewerType() === 'code'" class="smart-file-viewer__code animate-fadeIn w-full h-full">
 | 
				
			||||||
        <app-code-renderer [path]="filePath" [content]="resolvedContent()" />
 | 
					        <app-code-renderer [path]="filePath" [content]="resolvedContent()" />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -127,6 +127,18 @@ import { VideoPlayerComponent } from '../../app/features/note-view/components/vi
 | 
				
			|||||||
      overflow: auto;
 | 
					      overflow: auto;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /* Code viewer should align to top-left so header is always visible */
 | 
				
			||||||
 | 
					    .smart-file-viewer__code {
 | 
				
			||||||
 | 
					      flex: 1;
 | 
				
			||||||
 | 
					      display: flex;
 | 
				
			||||||
 | 
					      align-items: flex-start;
 | 
				
			||||||
 | 
					      justify-content: flex-start;
 | 
				
			||||||
 | 
					      padding: 1rem 1.25rem;
 | 
				
			||||||
 | 
					      overflow: auto;
 | 
				
			||||||
 | 
					      min-height: 0; /* allow proper flexbox scrolling */
 | 
				
			||||||
 | 
					      min-width: 0;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /* Excalidraw should stretch exactly like PDF */
 | 
					    /* Excalidraw should stretch exactly like PDF */
 | 
				
			||||||
    .smart-file-viewer__excalidraw {
 | 
					    .smart-file-viewer__excalidraw {
 | 
				
			||||||
      flex: 1;
 | 
					      flex: 1;
 | 
				
			||||||
@ -240,6 +252,12 @@ export class SmartFileViewerComponent implements OnChanges {
 | 
				
			|||||||
  // Internal fetched content for non-markdown text/code files
 | 
					  // Internal fetched content for non-markdown text/code files
 | 
				
			||||||
  private fetchedContent = signal<string>('');
 | 
					  private fetchedContent = signal<string>('');
 | 
				
			||||||
  resolvedContent = computed(() => {
 | 
					  resolvedContent = computed(() => {
 | 
				
			||||||
 | 
					    const vt = this.viewerType();
 | 
				
			||||||
 | 
					    // For code/text files, always prioritize fetched content
 | 
				
			||||||
 | 
					    if (vt === 'code' || vt === 'text') {
 | 
				
			||||||
 | 
					      return this.fetchedContent();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    // For other types, use input content if available
 | 
				
			||||||
    const c = this.contentSig();
 | 
					    const c = this.contentSig();
 | 
				
			||||||
    return (c && c.length > 0) ? c : this.fetchedContent();
 | 
					    return (c && c.length > 0) ? c : this.fetchedContent();
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
@ -278,11 +296,52 @@ export class SmartFileViewerComponent implements OnChanges {
 | 
				
			|||||||
        if (path.startsWith('.obsidian/')) {
 | 
					        if (path.startsWith('.obsidian/')) {
 | 
				
			||||||
          this.fetchedContent.set('');
 | 
					          this.fetchedContent.set('');
 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
          const url = `/vault/${encodeURI(path)}`;
 | 
					          const ts = Date.now();
 | 
				
			||||||
          fetch(url)
 | 
					          const url = `/vault/${encodeURI(path)}?v=${ts}`;
 | 
				
			||||||
            .then(r => r.text().catch(() => ''))
 | 
					          console.log(`[SmartFileViewer] Fetching ${vt} file content from: ${url}`);
 | 
				
			||||||
            .then(txt => this.fetchedContent.set(txt))
 | 
					          // Bypass HTTP cache to avoid 304 (no body) and always get fresh content
 | 
				
			||||||
            .catch(() => this.fetchedContent.set(''));
 | 
					          fetch(url, {
 | 
				
			||||||
 | 
					            cache: 'no-store',
 | 
				
			||||||
 | 
					            headers: {
 | 
				
			||||||
 | 
					              'Cache-Control': 'no-cache',
 | 
				
			||||||
 | 
					              'Pragma': 'no-cache',
 | 
				
			||||||
 | 
					              'If-Modified-Since': 'Mon, 01 Jan 1990 00:00:00 GMT'
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          })
 | 
				
			||||||
 | 
					            .then(async (r) => {
 | 
				
			||||||
 | 
					              try {
 | 
				
			||||||
 | 
					                const txt = await r.text();
 | 
				
			||||||
 | 
					                return txt ?? '';
 | 
				
			||||||
 | 
					              } catch (e) {
 | 
				
			||||||
 | 
					                console.warn(`[SmartFileViewer] Response without body for ${path} (status ${r.status}).`, e);
 | 
				
			||||||
 | 
					                return '';
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					            .then(async (txt) => {
 | 
				
			||||||
 | 
					              if (txt && txt.length > 0) {
 | 
				
			||||||
 | 
					                console.log(`[SmartFileViewer] Successfully fetched ${txt.length} chars for ${path}`);
 | 
				
			||||||
 | 
					                this.fetchedContent.set(txt);
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					              // Fallback: force a distinct URL to defeat proxy caches that ignore cache headers
 | 
				
			||||||
 | 
					              const url2 = `/vault/${encodeURI(path)}?vv=${Date.now()}`;
 | 
				
			||||||
 | 
					              console.log(`[SmartFileViewer] Fallback fetching content from: ${url2}`);
 | 
				
			||||||
 | 
					              const r2 = await fetch(url2, {
 | 
				
			||||||
 | 
					                cache: 'reload',
 | 
				
			||||||
 | 
					                headers: {
 | 
				
			||||||
 | 
					                  'Cache-Control': 'no-cache',
 | 
				
			||||||
 | 
					                  'Pragma': 'no-cache',
 | 
				
			||||||
 | 
					                  'If-Modified-Since': 'Mon, 01 Jan 1990 00:00:00 GMT'
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					              });
 | 
				
			||||||
 | 
					              const txt2 = await r2.text().catch(() => '');
 | 
				
			||||||
 | 
					              console.log(`[SmartFileViewer] Fallback fetched ${txt2.length} chars for ${path}`);
 | 
				
			||||||
 | 
					              this.fetchedContent.set(txt2);
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					            .catch(err => {
 | 
				
			||||||
 | 
					              console.error(`[SmartFileViewer] Failed to fetch ${path}:`, err);
 | 
				
			||||||
 | 
					              this.fetchedContent.set('');
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        this.fetchedContent.set('');
 | 
					        this.fetchedContent.set('');
 | 
				
			||||||
 | 
				
			|||||||
@ -77,6 +77,65 @@ console.log('hello world');
 | 
				
			|||||||
> citation
 | 
					> citation
 | 
				
			||||||
> avec plusieurs lignes
 | 
					> avec plusieurs lignes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
```javascript
 | 
					```python
 | 
				
			||||||
console.log('hello world');
 | 
					"""Exemple de script Python illustrant l'usage des dataclasses."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from dataclasses import dataclass, field
 | 
				
			||||||
 | 
					from datetime import datetime, timedelta
 | 
				
			||||||
 | 
					from typing import Iterable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@dataclass
 | 
				
			||||||
 | 
					class Task:
 | 
				
			||||||
 | 
					    """Représente une tâche planifiée avec une durée estimée."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    name: str
 | 
				
			||||||
 | 
					    estimated_minutes: int
 | 
				
			||||||
 | 
					    created_at: datetime = field(default_factory=datetime.now)
 | 
				
			||||||
 | 
					    completed: bool = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def remaining_minutes(self) -> int:
 | 
				
			||||||
 | 
					        """Calcule le temps restant estimé."""
 | 
				
			||||||
 | 
					        return 0 if self.completed else self.estimated_minutes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def mark_done(self) -> None:
 | 
				
			||||||
 | 
					        """Marque la tâche comme terminée."""
 | 
				
			||||||
 | 
					        self.completed = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def summarize(tasks: Iterable[Task]) -> str:
 | 
				
			||||||
 | 
					    """Retourne un résumé textuel de la charge de travail."""
 | 
				
			||||||
 | 
					    task_list = list(tasks)
 | 
				
			||||||
 | 
					    total = sum(task.estimated_minutes for task in task_list)
 | 
				
			||||||
 | 
					    remaining = sum(task.remaining_minutes() for task in task_list)
 | 
				
			||||||
 | 
					    done = total - remaining
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					        f"Tâches: {len(task_list)}\n"
 | 
				
			||||||
 | 
					        f"Terminé: {done} min\n"
 | 
				
			||||||
 | 
					        f"Restant: {remaining} min\n"
 | 
				
			||||||
 | 
					        f"Charge totale: {total} min"
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def main() -> None:
 | 
				
			||||||
 | 
					    """Point d'entrée du script avec quelques exemples."""
 | 
				
			||||||
 | 
					    tasks = [
 | 
				
			||||||
 | 
					        Task("Préparer la réunion", estimated_minutes=45),
 | 
				
			||||||
 | 
					        Task("Répondre aux emails", estimated_minutes=20),
 | 
				
			||||||
 | 
					        Task("Prototyper l'interface", estimated_minutes=90),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Marquer une tâche comme terminée pour illustrer la logique.
 | 
				
			||||||
 | 
					    tasks[1].mark_done()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    print("Résumé du jour:")
 | 
				
			||||||
 | 
					    print(summarize(tasks))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Afficher l'échéance approximative si toutes les tâches sont démarrées maintenant.
 | 
				
			||||||
 | 
					    deadline = datetime.now() + timedelta(minutes=sum(task.remaining_minutes() for task in tasks))
 | 
				
			||||||
 | 
					    print(f"Heure de fin estimée: {deadline:%H:%M}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if __name__ == "__main__":
 | 
				
			||||||
 | 
					    main()
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user