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