feat: add support for non-markdown files and improve drawings UI
- Added scanning and metadata tracking for non-markdown files (images, PDFs, videos, code files) - Redesigned drawings editor header with new toolbar layout and dropdown menus - Added file picker dropdown to easily open existing .excalidraw files - Implemented new file creation flow with auto-generated filenames - Added export options menu with PNG/SVG/JSON export variants - Updated proxy config to support vault file access - Adde
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { Component, Input, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-code-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
:host { display: block; height: 100%; }
|
||||
.root { height: 100%; display: flex; flex-direction: column; }
|
||||
.toolbar { display: flex; align-items: center; gap: .5rem; padding: .5rem .75rem; border-bottom: 1px solid var(--border); background: var(--card); }
|
||||
.btn { display: inline-flex; align-items: center; gap: .375rem; padding: .25rem .5rem; border-radius: .375rem; }
|
||||
.btn:hover { background: color-mix(in oklab, var(--card) 90%, black 10%); }
|
||||
.content { flex: 1; min-height: 0; overflow: auto; background: var(--card); }
|
||||
pre { margin: 0; padding: .75rem 1rem; font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); font-size: .875rem; line-height: 1.5; }
|
||||
.line { display: block; white-space: pre; }
|
||||
.nowrap .line { white-space: pre; }
|
||||
.wrap .line { white-space: pre-wrap; word-break: break-word; }
|
||||
.ln { color: var(--text-muted); user-select: none; width: 3ch; display: inline-block; text-align: right; padding-right: .75ch; }
|
||||
`],
|
||||
template: `
|
||||
<div class="root rounded-2xl border border-border bg-card">
|
||||
<div class="toolbar text-xs">
|
||||
<button type="button" class="btn" (click)="onFind()" title="Find (Ctrl/Cmd+F)">🔎 <span class="hidden sm:inline">Find</span></button>
|
||||
<button type="button" class="btn" (click)="toggleWrap()" title="Toggle wrap">⤶ <span class="hidden sm:inline">Wrap</span></button>
|
||||
<button type="button" class="btn" (click)="copyAll()" title="Copy all">📋 <span class="hidden sm:inline">Copy</span></button>
|
||||
<a class="btn" [href]="downloadHref()" download [attr.title]="'Download ' + fileName()">⬇️ <span class="hidden sm:inline">Download</span></a>
|
||||
<span class="ml-auto text-muted truncate" [title]="path">{{ fileName() }}</span>
|
||||
</div>
|
||||
<div class="content" [class.wrap]="wrap()" [class.nowrap]="!wrap()">
|
||||
<pre aria-label="Code content">
|
||||
<ng-container *ngFor="let line of lines(); let i = index">
|
||||
<span class="line"><span class="ln">{{ i + 1 }}</span>{{ line }}</span>
|
||||
</ng-container>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class CodeViewerComponent {
|
||||
@Input() path: string = '';
|
||||
@Input() content: string = '';
|
||||
@Input() editable: boolean = false; // reserved for future
|
||||
|
||||
wrap = signal<boolean>(false);
|
||||
|
||||
toggleWrap() { this.wrap.update(v => !v); }
|
||||
|
||||
fileName = computed(() => {
|
||||
const p = this.path || '';
|
||||
return p.split('/').pop() || p.split('\\').pop() || 'file';
|
||||
});
|
||||
|
||||
lines = computed(() => {
|
||||
const raw = this.content || '';
|
||||
// Light safeguard for huge files: cap at 5000 lines
|
||||
const parts = raw.split(/\r?\n/);
|
||||
return parts.slice(0, 5000);
|
||||
});
|
||||
|
||||
downloadHref() {
|
||||
if (!this.path) return '#';
|
||||
return `/api/files/${encodeURIComponent(this.path)}`;
|
||||
}
|
||||
|
||||
onFind() {
|
||||
try {
|
||||
// Let browser open find dialog
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
// No-op; Ctrl/Cmd+F handled by browser
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async copyAll() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.content || '');
|
||||
} catch (e) {
|
||||
console.warn('Copy failed', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,104 +1,92 @@
|
||||
<div class="flex flex-col h-[calc(100vh-180px)] lg:h-[calc(100vh-140px)] gap-2">
|
||||
<!-- En-tête avec les boutons et les états -->
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Indicateur de sauvegarde (cliquable) -->
|
||||
<button
|
||||
*ngIf="showInlineActions"
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost flex items-center gap-2"
|
||||
[class.cursor-pointer]="!isLoading() && !isSaving()"
|
||||
[class.cursor-wait]="isSaving()"
|
||||
[class.cursor-not-allowed]="isLoading()"
|
||||
(click)="saveNow()"
|
||||
[disabled]="isLoading()"
|
||||
[attr.aria-label]="dirty() ? 'Sauver maintenant' : 'Déjà sauvegardé'"
|
||||
title="{{dirty() ? 'Non sauvegardé - Cliquer pour sauvegarder (Ctrl+S)' : isSaving() ? 'Sauvegarde en cours...' : 'Sauvegardé'}}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
[class.text-red-500]="dirty() && !isSaving()"
|
||||
[class.text-muted]="!dirty() && !isSaving()"
|
||||
[class.text-yellow-500]="isSaving()"
|
||||
[class.animate-pulse]="isSaving()"
|
||||
>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
|
||||
<polyline points="17 21 17 13 7 13 7 21"></polyline>
|
||||
<polyline points="7 3 7 8 15 8"></polyline>
|
||||
</svg>
|
||||
<span *ngIf="showInlineActions" class="text-xs font-medium" [class.text-red-500]="dirty() && !isSaving()" [class.text-muted]="!dirty() && !isSaving()" [class.text-yellow-500]="isSaving()">
|
||||
{{isSaving() ? 'Sauvegarde...' : dirty() ? 'Non sauvegardé' : 'Sauvegardé'}}
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex flex-col h-full w-full min-h-0 gap-2 p-0">
|
||||
<!-- En-tête complet (parité page Test Excalidraw) -->
|
||||
<div class="relative z-[60]">
|
||||
<div class="flex flex-wrap items-center gap-2 px-1 pt-1">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
|
||||
<ng-container *ngIf="path; else nofile">
|
||||
<span class="ml-2 text-xs text-muted truncate select-text" [title]="path">{{ (path || '').split('/').pop() }}</span>
|
||||
</ng-container>
|
||||
<ng-template #nofile>
|
||||
<span class="ml-2 text-xs text-muted">Aucun fichier</span>
|
||||
</ng-template>
|
||||
<div class="ml-auto flex items-center">
|
||||
<span
|
||||
class="inline-flex h-7 w-7 items-center justify-center rounded-full border border-border/60 bg-card/70 transition-colors"
|
||||
[class.text-emerald-400]="!dirty()"
|
||||
[class.text-red-500]="dirty()"
|
||||
[title]="dirty() ? 'Dessin non sauvegardé' : 'Dessin sauvegardé'"
|
||||
aria-label="Statut de sauvegarde"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||
<polyline points="17 21 17 13 7 13 7 21" />
|
||||
<polyline points="7 3 7 8 15 8" />
|
||||
</svg>
|
||||
<span class="sr-only">{{ dirty() ? 'Non sauvegardé' : 'Sauvegardé' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
*ngIf="showInlineActions"
|
||||
type="button"
|
||||
class="btn btn-sm flex items-center gap-2"
|
||||
(click)="exportPNG()"
|
||||
[disabled]="isLoading() || isSaving() || !excalidrawReady"
|
||||
aria-label="Exporter en PNG"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="14" rx="2" ry="2"></rect>
|
||||
<circle cx="8" cy="8" r="2"></circle>
|
||||
<path d="M21 17l-5-6-4 5-2-3-5 6"></path>
|
||||
</svg>
|
||||
Export PNG
|
||||
<div class="flex flex-wrap items-center justify-start gap-1 px-1 pb-1">
|
||||
<button type="button" class="btn btn-ghost btn-xs h-8 w-8 p-0" (click)="createNew()" aria-label="Nouveau" title="Nouveau">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
||||
<span class="sr-only">Nouveau</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="showInlineActions"
|
||||
type="button"
|
||||
class="btn btn-sm flex items-center gap-2"
|
||||
(click)="exportSVG()"
|
||||
[disabled]="isLoading() || isSaving() || !excalidrawReady"
|
||||
aria-label="Exporter en SVG"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Export SVG
|
||||
<div class="relative">
|
||||
<button type="button" class="btn btn-ghost btn-xs h-8 w-8 p-0" (click)="toggleOpenPicker()" aria-haspopup="menu" aria-expanded="{{openPicker()}}" title="Ouvrir">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5l2 2h9a2 2 0 0 1 2 2z"/></svg>
|
||||
<span class="sr-only">Ouvrir</span>
|
||||
</button>
|
||||
<div *ngIf="openPicker()" class="absolute right-0 top-full mt-1 w-72 max-w-[80vw] max-h-72 overflow-y-auto rounded-xl border border-border bg-card shadow-2xl z-[80]">
|
||||
<div class="px-3 py-1.5 text-xs text-muted">Sélectionner un fichier *.excalidraw.md</div>
|
||||
<ul class="divide-y divide-border/40">
|
||||
<li *ngFor="let f of excalidrawFiles()">
|
||||
<button type="button" class="w-full text-left px-3 py-1.5 text-sm hover:bg-surface1 truncate" (click)="openFile(f.filePath)" [title]="f.filePath">{{ f.filePath }}</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost btn-xs h-8 w-8 p-0" (click)="closeFile()" [disabled]="!path" aria-label="Fermer le fichier" title="Fermer le fichier">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
<span class="sr-only">Fermer</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="showInlineActions"
|
||||
type="button"
|
||||
class="btn btn-sm flex items-center gap-2"
|
||||
(click)="toggleFullscreen()"
|
||||
[disabled]="isLoading()"
|
||||
[title]="isFullscreen() ? 'Quitter le mode pleine écran' : 'Passer en mode pleine écran'"
|
||||
aria-label="Basculer le mode plein écran"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
[class.text-blue-500]="isFullscreen()"
|
||||
>
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
|
||||
<polyline points="10,17 15,12 10,7"></polyline>
|
||||
<line x1="15" x2="3" y1="12" y2="12"></line>
|
||||
<button type="button" class="btn btn-ghost btn-xs h-8 w-8 p-0" (click)="saveDebounced()" [disabled]="!path" aria-label="Enregistrer" title="Enregistrer (Ctrl/Cmd+S)">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h11l5 5v9a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
<span class="sr-only">Enregistrer</span>
|
||||
</button>
|
||||
<div class="relative">
|
||||
<button type="button" class="btn btn-ghost btn-xs h-8 w-8 p-0" (click)="toggleExport()" aria-haspopup="menu" aria-expanded="{{openExport()}}" title="Exporter">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7 11l5 5 5-5"/><path d="M5 19h14"/></svg>
|
||||
<span class="sr-only">Exporter</span>
|
||||
</button>
|
||||
<div *ngIf="openExport()" class="absolute right-0 top-full mt-1 w-64 max-w-[75vw] rounded-xl border border-border bg-card shadow-2xl z-[80]">
|
||||
<button class="w-full text-left px-3 py-1.5 text-sm hover:bg-surface1" (click)="exportPNG(true)">PNG (avec fond)</button>
|
||||
<button class="w-full text-left px-3 py-1.5 text-sm hover:bg-surface1" (click)="exportPNG(false)">PNG (sans fond)</button>
|
||||
<div class="h-px bg-border mx-2"></div>
|
||||
<button class="w-full text-left px-3 py-1.5 text-sm hover:bg-surface1" (click)="exportSVG(true)">SVG (embed)</button>
|
||||
<button class="w-full text-left px-3 py-1.5 text-sm hover:bg-surface1" (click)="exportSVG(false)">SVG</button>
|
||||
<div class="h-px bg-border mx-2"></div>
|
||||
<button class="w-full text-left px-3 py-1.5 text-sm hover:bg-surface1" (click)="exportJSON()">JSON (télécharger)</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost btn-xs h-8 w-8 p-0" (click)="toggleFullscreen()" [disabled]="isLoading()" aria-label="Basculer plein écran" [title]="isFullscreen() ? 'Quitter le plein écran' : 'Plein écran'">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path *ngIf="!isFullscreen(); else exitIcon" d="M15 3h6v6" />
|
||||
<path *ngIf="!isFullscreen()" d="M9 21H3v-6" />
|
||||
<path *ngIf="!isFullscreen()" d="M21 15v6h-6" />
|
||||
<path *ngIf="!isFullscreen()" d="M3 9V3h6" />
|
||||
</svg>
|
||||
{{isFullscreen() ? 'Quitter FS' : 'Plein écran'}}
|
||||
<ng-template #exitIcon>
|
||||
<path d="M9 3H3v6" />
|
||||
<path d="M15 21h6v-6" />
|
||||
<path d="M3 15v6h6" />
|
||||
<path d="M21 9V3h-6" />
|
||||
</ng-template>
|
||||
<span class="sr-only">Plein écran</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conflit: fichier modifié sur le disque -->
|
||||
<div *ngIf="hasConflict()" class="rounded-md border border-amber-600 bg-amber-900/30 text-amber-200 px-3 py-2">
|
||||
@@ -120,9 +108,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fin de l'en-tête -->
|
||||
</div>
|
||||
|
||||
<!-- État de chargement -->
|
||||
<div *ngIf="isLoading()" class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
@@ -134,7 +119,7 @@
|
||||
<!-- Éditeur Excalidraw -->
|
||||
<div
|
||||
*ngIf="!isLoading()"
|
||||
class="flex-1 min-h-0 rounded-xl border border-border bg-card overflow-hidden relative excalidraw-host"
|
||||
class="flex-1 min-h-0 overflow-hidden relative excalidraw-host rounded-lg border border-border bg-card"
|
||||
[class.opacity-50]="isSaving()"
|
||||
>
|
||||
<excalidraw-editor
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
ElementRef,
|
||||
HostListener,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
SimpleChanges,
|
||||
ViewChild,
|
||||
computed,
|
||||
inject,
|
||||
@@ -21,6 +23,7 @@ import { DrawingsFileService, ExcalidrawScene } from './drawings-file.service';
|
||||
import { ExcalidrawIoService } from './excalidraw-io.service';
|
||||
import { DrawingsPreviewService } from './drawings-preview.service';
|
||||
import { ThemeService } from '../../core/services/theme.service';
|
||||
import { VaultService } from '../../../services/vault.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-drawings-editor',
|
||||
@@ -30,7 +33,7 @@ import { ThemeService } from '../../core/services/theme.service';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
})
|
||||
export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy, OnChanges {
|
||||
@Input() path: string = '';
|
||||
@Input() showInlineActions: boolean = true;
|
||||
|
||||
@@ -43,6 +46,7 @@ export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy
|
||||
private readonly excalIo = inject(ExcalidrawIoService);
|
||||
private readonly previews = inject(DrawingsPreviewService);
|
||||
private readonly theme = inject(ThemeService);
|
||||
private readonly vault = inject(VaultService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
// Propriétés réactives accessibles depuis le template
|
||||
@@ -56,6 +60,11 @@ export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy
|
||||
toastType = signal<'success' | 'error' | 'info'>('info');
|
||||
hasConflict = signal<boolean>(false);
|
||||
isFullscreen = signal<boolean>(false);
|
||||
// Header menus & helpers (ported from Test Excalidraw)
|
||||
openPicker = signal(false);
|
||||
openExport = signal(false);
|
||||
excalidrawFiles = signal<Array<{ filePath: string }>>([]);
|
||||
private saveTimer: any = null;
|
||||
|
||||
private saveSub: Subscription | null = null;
|
||||
private dirtyCheckSub: Subscription | null = null;
|
||||
@@ -79,6 +88,77 @@ export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy
|
||||
}
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['path'] && !changes['path'].firstChange) {
|
||||
// Reset state and reload when the selected file changes
|
||||
this.error.set(null);
|
||||
this.hasConflict.set(false);
|
||||
this.isLoading.set(true);
|
||||
this.dirty.set(false);
|
||||
this.reloadFromDisk();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Test-page parity helpers =====
|
||||
refreshFileList(): void {
|
||||
try {
|
||||
const notes = this.vault.allNotes();
|
||||
const list = notes
|
||||
.filter(n => /\.excalidraw\.md$/i.test(n.filePath || ''))
|
||||
.map(n => ({ filePath: n.filePath }));
|
||||
this.excalidrawFiles.set(list);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
toggleOpenPicker(): void {
|
||||
this.openPicker.update(v => !v);
|
||||
if (this.openPicker()) this.refreshFileList();
|
||||
}
|
||||
|
||||
toggleExport(): void {
|
||||
this.openExport.update(v => !v);
|
||||
}
|
||||
|
||||
async createNew(): Promise<void> {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
const hh = String(now.getHours()).padStart(2, '0');
|
||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||
const base = `Drawing-${y}${m}${d}-${hh}${mm}`;
|
||||
const name = prompt('Nom du fichier', `${base}.excalidraw.md`);
|
||||
if (!name) return;
|
||||
const file = name.endsWith('.excalidraw.md') ? name : `${name}.excalidraw.md`;
|
||||
const path = file.replace(/^\/+/, '');
|
||||
const scene: ExcalidrawScene = { elements: [], appState: {}, files: {} };
|
||||
const fm = `---\nexcalidraw-plugin: parsed\ncreated: "${now.toISOString()}"\nupdated: "${now.toISOString()}"\ntitle: "${(file || '').replace(/\.excalidraw\.md$/i,'')}"\n---`;
|
||||
const md = this.excalIo.toObsidianMd(scene as any, fm);
|
||||
await firstValueFrom(this.files.putText(path, md));
|
||||
this.path = path;
|
||||
this.dirty.set(false);
|
||||
this.openPicker.set(false);
|
||||
this.reloadFromDisk();
|
||||
}
|
||||
|
||||
openFile(path: string): void {
|
||||
this.path = path;
|
||||
this.openPicker.set(false);
|
||||
this.dirty.set(false);
|
||||
this.reloadFromDisk();
|
||||
}
|
||||
|
||||
closeFile(): void {
|
||||
this.path = '';
|
||||
this.scene.set({ elements: [], appState: { viewBackgroundColor: '#1e1e1e', theme: this.themeName() }, files: {} } as any);
|
||||
this.dirty.set(false);
|
||||
}
|
||||
|
||||
async saveDebounced(): Promise<void> {
|
||||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||
this.saveTimer = setTimeout(() => this.saveNow(), 800);
|
||||
}
|
||||
|
||||
private waitForNextSceneChange(host: any, timeoutMs = 300): Promise<ExcalidrawScene | null> {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
@@ -719,6 +799,7 @@ export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.saveSub?.unsubscribe();
|
||||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||
// Clean up fullscreen listener
|
||||
document.removeEventListener('fullscreenchange', () => {
|
||||
this.isFullscreen.set(!!document.fullscreenElement);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { NoteContextMenuService } from '../../services/note-context-menu.service
|
||||
import { UrlStateService } from '../../services/url-state.service';
|
||||
import { EditorStateService } from '../../../services/editor-state.service';
|
||||
import { VaultService } from '../../../services/vault.service';
|
||||
import { FileTypeDetectorService } from '../../../services/file-type-detector.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notes-list',
|
||||
@@ -79,6 +80,11 @@ import { VaultService } from '../../../services/vault.service';
|
||||
title="Mode d'affichage">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Active Kind Icon (to the right of the two icons) -->
|
||||
<span *ngIf="kindFilter() && kindFilter() !== 'all'" class="inline-flex items-center justify-center w-8 h-8 text-sm rounded-md bg-surface2/40 dark:bg-surface2/30" title="Filtre type actif">
|
||||
{{ kindIcon(kindFilter()!) }}
|
||||
</span>
|
||||
|
||||
<!-- Sort Dropdown Menu -->
|
||||
<div *ngIf="sortMenuOpen()" class="absolute top-full left-0 mt-1 bg-card dark:bg-main border border-border dark:border-gray-700 rounded shadow-lg z-10 min-w-max">
|
||||
@@ -103,17 +109,23 @@ import { VaultService } from '../../../services/vault.service';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Request Status Indicator (right side) -->
|
||||
<div *ngIf="state.lastRequestStats() as stats" class="flex items-center text-xs text-muted">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span *ngIf="stats.success" class="inline-flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>
|
||||
{{ stats.duration }}ms
|
||||
</span>
|
||||
<span *ngIf="!stats.success" class="inline-flex items-center gap-1 text-red-600 dark:text-red-400">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>
|
||||
{{ stats.duration }}ms
|
||||
</span>
|
||||
<!-- Count + Request Status Indicator (right side) -->
|
||||
<div class="flex items-center gap-3 text-xs text-muted">
|
||||
<div class="inline-flex items-center gap-1" title="Nombre d'éléments filtrés">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="9"/></svg>
|
||||
{{ filtered().length }}
|
||||
</div>
|
||||
<div *ngIf="state.lastRequestStats() as stats" class="flex items-center">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span *ngIf="stats.success" class="inline-flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>
|
||||
{{ stats.duration }}ms
|
||||
</span>
|
||||
<span *ngIf="!stats.success" class="inline-flex items-center gap-1 text-red-600 dark:text-red-400">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>
|
||||
{{ stats.duration }}ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,12 +178,14 @@ import { VaultService } from '../../../services/vault.service';
|
||||
<!-- Compact View -->
|
||||
<div *ngIf="state.viewMode() === 'compact'" class="note-inner flex items-center gap-2">
|
||||
<span class="note-color-dot flex-shrink-0" [style.backgroundColor]="getNoteColor(n)" aria-hidden="true"></span>
|
||||
<span class="flex-shrink-0" title="Type">{{ typeIcon(n) }}</span>
|
||||
<div class="title text-xs truncate">{{ n.title }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Comfortable View (default) -->
|
||||
<div *ngIf="state.viewMode() === 'comfortable'" class="note-inner flex items-start gap-2">
|
||||
<span class="note-color-dot flex-shrink-0 mt-1" [style.backgroundColor]="getNoteColor(n)" aria-hidden="true"></span>
|
||||
<span class="flex-shrink-0 mt-0.5" title="Type">{{ typeIcon(n) }}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="title text-sm truncate">{{ n.title }}</div>
|
||||
<div class="meta text-xs truncate">{{ n.filePath }}</div>
|
||||
@@ -181,6 +195,7 @@ import { VaultService } from '../../../services/vault.service';
|
||||
<!-- Detailed View -->
|
||||
<div *ngIf="state.viewMode() === 'detailed'" class="note-inner flex items-start gap-2 space-y-0">
|
||||
<span class="note-color-dot flex-shrink-0 mt-1" [style.backgroundColor]="getNoteColor(n)" aria-hidden="true"></span>
|
||||
<span class="flex-shrink-0 mt-0.5" title="Type">{{ typeIcon(n) }}</span>
|
||||
<div class="min-w-0 flex-1 space-y-1.5">
|
||||
<div class="title text-sm truncate">{{ n.title }}</div>
|
||||
<div class="meta text-xs truncate">{{ n.filePath }}</div>
|
||||
@@ -466,6 +481,7 @@ export class NotesListComponent {
|
||||
tagFilter = input<string | null>(null);
|
||||
quickLinkFilter = input<'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null>(null);
|
||||
selectedId = input<string | null>(null);
|
||||
kindFilter = input<'image' | 'video' | 'pdf' | 'markdown' | 'excalidraw' | 'code' | 'all' | null>(null);
|
||||
|
||||
@Output() openNote = new EventEmitter<string>();
|
||||
@Output() queryChange = new EventEmitter<string>();
|
||||
@@ -482,6 +498,7 @@ export class NotesListComponent {
|
||||
private pendingSelectId = signal<string | null>(null);
|
||||
private editorState = inject(EditorStateService);
|
||||
private vault = inject(VaultService);
|
||||
private fileTypes = inject(FileTypeDetectorService);
|
||||
|
||||
// Delete warning modal state
|
||||
deleteWarningOpen = signal<boolean>(false);
|
||||
@@ -539,6 +556,80 @@ export class NotesListComponent {
|
||||
}, 10);
|
||||
});
|
||||
|
||||
private buildUnifiedList(): Note[] {
|
||||
const notes = this.notes();
|
||||
const notePaths = new Set(notes.map(n => (n.filePath || '').toLowerCase().replace(/\\/g, '/')));
|
||||
const otherFiles = this.collectFilesFromFastTree()
|
||||
.filter(f => !notePaths.has(f.filePath.toLowerCase().replace(/\\/g, '/')))
|
||||
.map(f => this.asNoteLike(f));
|
||||
return [...notes, ...otherFiles];
|
||||
}
|
||||
|
||||
private collectFilesFromFastTree(): Array<{ id: string; filePath: string; fileName: string; originalPath: string }> {
|
||||
const out: Array<{ id: string; filePath: string; fileName: string; originalPath: string }> = [];
|
||||
const visit = (nodes: any[], parentPath: string = '') => {
|
||||
for (const n of nodes || []) {
|
||||
if (n.type === 'folder') {
|
||||
visit(n.children, n.path || parentPath);
|
||||
} else if (n.type === 'file') {
|
||||
const filePath = (n.path || '').replace(/^\/+/, '');
|
||||
const fileName = n.name || filePath.split('/').pop() || '';
|
||||
const originalPath = (n.path || '').split('/').slice(0, -1).join('/');
|
||||
out.push({ id: n.id || filePath, filePath, fileName, originalPath });
|
||||
}
|
||||
}
|
||||
};
|
||||
try { visit(this.vault.fastFileTree()); } catch {}
|
||||
return out;
|
||||
}
|
||||
|
||||
private asNoteLike(f: { id: string; filePath: string; fileName: string; originalPath: string }): Note {
|
||||
return {
|
||||
id: f.id,
|
||||
title: f.fileName,
|
||||
content: '',
|
||||
rawContent: '',
|
||||
tags: [],
|
||||
frontmatter: {} as any,
|
||||
backlinks: [],
|
||||
mtime: 0,
|
||||
fileName: f.fileName,
|
||||
filePath: f.filePath,
|
||||
originalPath: f.originalPath,
|
||||
} as Note;
|
||||
}
|
||||
|
||||
kindIcon(k: 'image'|'video'|'pdf'|'markdown'|'excalidraw'|'code'|'all'): string {
|
||||
switch (k) {
|
||||
case 'markdown': return '📝';
|
||||
case 'excalidraw': return '✏️';
|
||||
case 'pdf': return '📄';
|
||||
case 'image': return '🖼️';
|
||||
case 'video': return '🎬';
|
||||
case 'code': return '</>';
|
||||
default: return '✨';
|
||||
}
|
||||
}
|
||||
|
||||
private matchesKind(n: Note, kind: 'image' | 'video' | 'pdf' | 'markdown' | 'excalidraw' | 'code' | 'all'): boolean {
|
||||
if (!kind || kind === 'all') return true;
|
||||
const t = this.fileTypes.getViewerType(n.filePath, n.rawContent ?? n.content);
|
||||
return t === kind;
|
||||
}
|
||||
|
||||
typeIcon(n: Note): string {
|
||||
const t = this.fileTypes.getViewerType(n.filePath, n.rawContent ?? n.content);
|
||||
switch (t) {
|
||||
case 'markdown': return '📝';
|
||||
case 'excalidraw': return '✏️';
|
||||
case 'pdf': return '📄';
|
||||
case 'image': return '🖼️';
|
||||
case 'video': return '🎬';
|
||||
case 'code': return '</>';
|
||||
default: return '📎';
|
||||
}
|
||||
}
|
||||
|
||||
private scrollToSelectedEffect = effect(() => {
|
||||
const id = this.selectedId() || this.pendingSelectId();
|
||||
if (!id) return;
|
||||
@@ -592,8 +683,10 @@ export class NotesListComponent {
|
||||
const folder = (this.folderFilter() || '').toLowerCase().replace(/^\/+|\/+$/g, '');
|
||||
const tag = (this.activeTag() || '').toLowerCase();
|
||||
const quickLink = this.quickLinkFilter();
|
||||
const kind = this.kindFilter();
|
||||
const sortBy = this.state.sortBy();
|
||||
let list = this.notes();
|
||||
// Build source list: notes + other files (images, pdf, video, code, etc.)
|
||||
let list = this.buildUnifiedList();
|
||||
|
||||
if (folder !== '.trash') {
|
||||
list = list.filter(n => {
|
||||
@@ -635,6 +728,11 @@ export class NotesListComponent {
|
||||
});
|
||||
}
|
||||
|
||||
// Kind filter (file type)
|
||||
if (kind && kind !== 'all') {
|
||||
list = list.filter(n => this.matchesKind(n, kind));
|
||||
}
|
||||
|
||||
const parseDate = (s?: string) => (s ? Date.parse(s) : 0) || 0;
|
||||
return [...list].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Component, Input, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import javascript from 'highlight.js/lib/languages/javascript';
|
||||
import typescript from 'highlight.js/lib/languages/typescript';
|
||||
import json from 'highlight.js/lib/languages/json';
|
||||
import python from 'highlight.js/lib/languages/python';
|
||||
import java from 'highlight.js/lib/languages/java';
|
||||
import xml from 'highlight.js/lib/languages/xml';
|
||||
import css from 'highlight.js/lib/languages/css';
|
||||
import bash from 'highlight.js/lib/languages/bash';
|
||||
import yaml from 'highlight.js/lib/languages/yaml';
|
||||
import sql from 'highlight.js/lib/languages/sql';
|
||||
|
||||
hljs.registerLanguage('javascript', javascript);
|
||||
hljs.registerLanguage('typescript', typescript);
|
||||
hljs.registerLanguage('json', json);
|
||||
hljs.registerLanguage('python', python);
|
||||
hljs.registerLanguage('java', java);
|
||||
hljs.registerLanguage('xml', xml);
|
||||
hljs.registerLanguage('html', xml);
|
||||
hljs.registerLanguage('css', css);
|
||||
hljs.registerLanguage('bash', bash);
|
||||
hljs.registerLanguage('yaml', yaml);
|
||||
hljs.registerLanguage('yml', yaml);
|
||||
hljs.registerLanguage('sql', sql);
|
||||
|
||||
@Component({
|
||||
selector: 'app-code-renderer',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
:host { display:block; }
|
||||
.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%); }
|
||||
.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); }
|
||||
.lang { margin-left:auto; padding:.125rem .5rem; border:1px solid var(--border); border-radius:.5rem; font-weight:600; 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; }
|
||||
code { display:block; }
|
||||
.hljs { color: var(--text-main); }
|
||||
`],
|
||||
template: `
|
||||
<div class="root animate-fadeIn">
|
||||
<div class="header">
|
||||
<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">
|
||||
<path d="M16 18l6-6-6-6"/>
|
||||
<path d="M8 6L2 12l6 6"/>
|
||||
</svg>
|
||||
<span>code</span>
|
||||
</span>
|
||||
<span class="lang">{{ languageLabel() }}</span>
|
||||
</div>
|
||||
<pre><code class="hljs" [innerHTML]="highlighted()"></code></pre>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class CodeRendererComponent {
|
||||
@Input() path: string = '';
|
||||
@Input() content: string = '';
|
||||
|
||||
fileName = computed(() => {
|
||||
const p = this.path || '';
|
||||
return p.split('/').pop() || p.split('\\').pop() || 'code';
|
||||
});
|
||||
|
||||
languageLabel = computed(() => {
|
||||
const src = this.content || '';
|
||||
if (!src.trim()) return 'TEXT';
|
||||
try {
|
||||
const res = hljs.highlightAuto(src);
|
||||
return (res.language || 'text').toUpperCase();
|
||||
} catch {
|
||||
return 'TEXT';
|
||||
}
|
||||
});
|
||||
|
||||
highlighted = computed(() => {
|
||||
const src = this.content || '';
|
||||
try {
|
||||
if (!src.trim()) { return hljs.highlightAuto('').value; }
|
||||
const res = hljs.highlightAuto(src);
|
||||
return res.value;
|
||||
} catch {
|
||||
return hljs.highlightAuto(src).value;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Component, EventEmitter, Input, Output, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
export interface FileMetadataView {
|
||||
type: string;
|
||||
size?: number;
|
||||
sizeHuman?: string;
|
||||
path: string;
|
||||
createdAt?: string;
|
||||
modifiedAt?: string;
|
||||
dimensions?: { width: number; height: number };
|
||||
duration?: string;
|
||||
codec?: string;
|
||||
frameRate?: number;
|
||||
dataRate?: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-file-info-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
:host { display:block; }
|
||||
.panel { position: absolute; top: 0; right: 0; height: 100%; width: min(88vw, 320px); background: var(--card); border-left: 1px solid var(--border); box-shadow: 0 8px 30px rgba(0,0,0,.18); transform: translateX(100%); transition: transform .25s ease, opacity .25s ease; opacity: .98; border-top-left-radius: 12px; border-bottom-left-radius: 12px; }
|
||||
.panel.open { transform: translateX(0); }
|
||||
.header { height: 44px; display:flex; align-items:center; gap:.5rem; padding: 0 .75rem; border-bottom:1px solid var(--border); }
|
||||
.content { padding: .75rem .75rem 1rem; font-size: .875rem; }
|
||||
.row { display:flex; align-items:flex-start; gap:.5rem; padding:.4rem 0; border-bottom:1px dashed var(--border); }
|
||||
.row:last-child { border-bottom: 0; }
|
||||
.k { color: var(--text-muted); width: 120px; flex: 0 0 auto; }
|
||||
.v { color: var(--text-main); word-break: break-word; }
|
||||
.close { margin-left:auto; border-radius:8px; width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center; }
|
||||
.close:hover { background: color-mix(in oklab, var(--card) 90%, black 10%); }
|
||||
`],
|
||||
template: `
|
||||
<div class="panel" [class.open]="visible">
|
||||
<div class="header">
|
||||
<strong>Détails</strong>
|
||||
<button type="button" class="close" (click)="close.emit()" title="Fermer">✕</button>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="row"><div class="k">Type</div><div class="v">{{ data?.type || '—' }}</div></div>
|
||||
<div class="row" *ngIf="data?.size != null"><div class="k">Taille</div><div class="v">{{ data!.size | number }} o <span class="text-muted">({{ data!.sizeHuman }})</span></div></div>
|
||||
<div class="row"><div class="k">Chemin</div><div class="v">{{ data?.path }}</div></div>
|
||||
<div class="row" *ngIf="data?.createdAt"><div class="k">Créé</div><div class="v">{{ data!.createdAt | date:'medium' }}</div></div>
|
||||
<div class="row" *ngIf="data?.modifiedAt"><div class="k">Modifié</div><div class="v">{{ data!.modifiedAt | date:'medium' }}</div></div>
|
||||
<div class="row" *ngIf="data?.dimensions"><div class="k">Dimensions</div><div class="v">{{ data!.dimensions!.width }} × {{ data!.dimensions!.height }} px</div></div>
|
||||
<div class="row" *ngIf="data?.duration"><div class="k">Durée</div><div class="v">{{ data!.duration }}</div></div>
|
||||
<div class="row" *ngIf="data?.frameRate"><div class="k">Frame rate</div><div class="v">{{ data!.frameRate }} fps</div></div>
|
||||
<div class="row" *ngIf="data?.codec"><div class="k">Codec</div><div class="v">{{ data!.codec }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class FileInfoPanelComponent {
|
||||
@Input() visible: boolean = false;
|
||||
@Input() data: FileMetadataView | null = null;
|
||||
@Output() close = new EventEmitter<void>();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-image-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="img-root">
|
||||
<img [src]="src" [alt]="alt" (load)="onLoad($event)" class="media" loading="lazy" />
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host { display:block; height:100%; }
|
||||
.img-root { height:100%; display:flex; align-items:center; justify-content:center; padding: 1rem; }
|
||||
.media { max-width: 100%; max-height: 90vh; object-fit: contain; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.16); }
|
||||
`]
|
||||
})
|
||||
export class ImageViewerComponent {
|
||||
@Input() src: string = '';
|
||||
@Input() alt: string = '';
|
||||
@Output() dimensions = new EventEmitter<{ width: number; height: number }>();
|
||||
|
||||
onLoad(ev: Event) {
|
||||
const el = ev.target as HTMLImageElement | null;
|
||||
if (!el) return;
|
||||
this.dimensions.emit({ width: el.naturalWidth || el.width || 0, height: el.naturalHeight || el.height || 0 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="w-full h-full overflow-auto">
|
||||
<ngx-extended-pdf-viewer
|
||||
[src]="cleanSrc()"
|
||||
[zoom]="'page-width'"
|
||||
height="100%">
|
||||
</ngx-extended-pdf-viewer>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Component, Input, computed, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||
|
||||
@Component({
|
||||
selector: 'app-pdf-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="pdf-container">
|
||||
<div class="pdf-header">
|
||||
<span class="pdf-icon">📄</span>
|
||||
<span class="pdf-title">PDF</span>
|
||||
<span class="pdf-filename">{{ fileName() }}</span>
|
||||
</div>
|
||||
<iframe class="viewer" [src]="safeSrc()" title="PDF Viewer"></iframe>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host { display:flex; flex:1 1 auto; width:100%; min-width:0; min-height:0; padding: 0; }
|
||||
.pdf-container {
|
||||
flex:1 1 auto;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
min-height:0;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
overflow: hidden;
|
||||
}
|
||||
.pdf-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--card) 95%, var(--text-main) 5%);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.pdf-icon { font-size: 1.25rem; }
|
||||
.pdf-title {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.pdf-filename {
|
||||
margin-left: auto;
|
||||
font-weight: 500;
|
||||
color: var(--text-main);
|
||||
max-width: 50%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.viewer { flex:1 1 auto; display:block; width:100%; height:100%; border:0; min-height:0; }
|
||||
`]
|
||||
})
|
||||
export class PdfViewerComponent {
|
||||
@Input() src: string = '';
|
||||
@Input() path: string = '';
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
|
||||
safeSrc = computed<SafeResourceUrl>(() => this.sanitizer.bypassSecurityTrustResourceUrl(this.src || ''));
|
||||
|
||||
fileName = computed(() => {
|
||||
const p = this.path || this.src || '';
|
||||
try {
|
||||
const u = decodeURI(p);
|
||||
return u.split('/').pop() || u.split('\\').pop() || 'document.pdf';
|
||||
} catch {
|
||||
return p.split('/').pop() || p.split('\\').pop() || 'document.pdf';
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-video-player',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
:host { display:block; height:100%; }
|
||||
.root { height:100%; display:flex; align-items:center; justify-content:center; background: var(--card); border-radius: 12px; overflow:hidden; border:1px solid var(--border); }
|
||||
video { width: 100%; height: 100%; max-height: 90vh; background: black; }
|
||||
`],
|
||||
template: `
|
||||
<div class="root">
|
||||
<video [src]="src" controls playsinline (loadedmetadata)="onMeta($event)"></video>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class VideoPlayerComponent {
|
||||
@Input() src: string = '';
|
||||
@Output() metadata = new EventEmitter<{ width: number; height: number; duration: number }>();
|
||||
|
||||
onMeta(ev: Event) {
|
||||
const v = ev.target as HTMLVideoElement | null;
|
||||
if (!v) return;
|
||||
this.metadata.emit({ width: v.videoWidth || 0, height: v.videoHeight || 0, duration: isFinite(v.duration) ? v.duration : 0 });
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,24 @@
|
||||
|
||||
<ng-container *ngIf="props as current; else emptyState">
|
||||
<div class="space-y-4">
|
||||
<ng-container *ngIf="fileInfo() as fi">
|
||||
<section class="not-prose">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-semibold mb-2">Infos fichier</h4>
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
|
||||
<dt class="text-muted-foreground whitespace-nowrap font-semibold">Type</dt>
|
||||
<dd class="font-medium break-words">{{ fi.type }}</dd>
|
||||
<dt class="text-muted-foreground whitespace-nowrap font-semibold">Taille</dt>
|
||||
<dd class="font-medium break-words">{{ fi.size || 0 | number }} o <span class="text-muted-foreground" *ngIf="fi.sizeHuman">({{ fi.sizeHuman }})</span></dd>
|
||||
<dt class="text-muted-foreground whitespace-nowrap font-semibold">Chemin</dt>
|
||||
<dd class="font-medium break-words">{{ fi.path }}</dd>
|
||||
<dt class="text-muted-foreground whitespace-nowrap font-semibold" *ngIf="fi.createdAt">Créé le</dt>
|
||||
<dd class="font-medium break-words" *ngIf="fi.createdAt">{{ formatDate(fi.createdAt) }}</dd>
|
||||
<dt class="text-muted-foreground whitespace-nowrap font-semibold" *ngIf="fi.modifiedAt">Modifié le</dt>
|
||||
<dd class="font-medium break-words" *ngIf="fi.modifiedAt">{{ formatDate(fi.modifiedAt) }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="hasSummary">
|
||||
<section class="not-prose">
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
|
||||
|
||||
+62
-3
@@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { Component, EventEmitter, Input, Output, inject, OnChanges, SimpleChanges, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { StateChipComponent } from '../state-chip/state-chip.component';
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../../shared/note-properties.model';
|
||||
import { VaultService } from '../../../../../services/vault.service';
|
||||
import { FrontmatterPropertiesService } from '../../shared/frontmatter-properties.service';
|
||||
import { FileTypeDetectorService } from '../../../../../services/file-type-detector.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-properties-popover',
|
||||
@@ -16,14 +17,63 @@ import { FrontmatterPropertiesService } from '../../shared/frontmatter-propertie
|
||||
imports: [CommonModule, StateChipComponent],
|
||||
templateUrl: './properties-popover.component.html'
|
||||
})
|
||||
export class PropertiesPopoverComponent {
|
||||
export class PropertiesPopoverComponent implements OnChanges {
|
||||
@Input() props: NoteProperties | null = null;
|
||||
@Input() noteId: string | null = null;
|
||||
private _noteId: string | null = null;
|
||||
@Input() set noteId(value: string | null) {
|
||||
this._noteId = value;
|
||||
// Trigger refresh when input is assigned programmatically by overlay
|
||||
this.refreshFileInfo();
|
||||
}
|
||||
get noteId(): string | null { return this._noteId; }
|
||||
@Output() requestClose = new EventEmitter<void>();
|
||||
@Output() cancelClose = new EventEmitter<void>();
|
||||
|
||||
private vault = inject(VaultService);
|
||||
private frontmatter = inject(FrontmatterPropertiesService);
|
||||
private fileType = inject(FileTypeDetectorService);
|
||||
|
||||
// Lightweight file info for non-markdown
|
||||
readonly fileInfo = signal<{
|
||||
type: string;
|
||||
path: string;
|
||||
size?: number;
|
||||
sizeHuman?: string;
|
||||
createdAt?: string;
|
||||
modifiedAt?: string;
|
||||
} | null>(null);
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['noteId']) {
|
||||
this.refreshFileInfo();
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshFileInfo(): Promise<void> {
|
||||
const id = this.noteId;
|
||||
if (!id) { this.fileInfo.set(null); return; }
|
||||
const note = this.vault.getNoteById(id);
|
||||
if (!note?.filePath) { this.fileInfo.set(null); return; }
|
||||
const type = this.fileType.getViewerType(note.filePath, note.rawContent ?? note.content);
|
||||
if (type === 'markdown' || type === 'excalidraw') { this.fileInfo.set(null); return; }
|
||||
|
||||
const meta = this.vault.getFastMetaByPath(note.filePath);
|
||||
let size: number | undefined;
|
||||
try {
|
||||
const res = await fetch(`/vault/${encodeURI(note.filePath)}`, { method: 'HEAD' });
|
||||
const len = res.headers.get('content-length');
|
||||
size = len ? Number(len) : undefined;
|
||||
} catch {}
|
||||
|
||||
this.fileInfo.set({
|
||||
type,
|
||||
path: note.filePath,
|
||||
size,
|
||||
sizeHuman: this.formatSize(size),
|
||||
createdAt: meta?.createdAt,
|
||||
modifiedAt: meta?.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
private readonly summaryConfig: Array<{
|
||||
key: keyof NotePropertySummary;
|
||||
@@ -95,6 +145,15 @@ export class PropertiesPopoverComponent {
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
private formatSize(bytes?: number): string | undefined {
|
||||
if (bytes == null || !Number.isFinite(bytes)) return undefined;
|
||||
const units = ['o','Ko','Mo','Go','To'];
|
||||
let b = Math.max(0, Number(bytes));
|
||||
let i = 0;
|
||||
while (b >= 1024 && i < units.length - 1) { b /= 1024; i++; }
|
||||
return `${b.toFixed(b < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
hasStates(): boolean {
|
||||
return this.stateEntries.length > 0;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrol
|
||||
import type { VaultNode, TagInfo } from '../../../types';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { VaultService } from '../../../services/vault.service';
|
||||
import { UrlStateService } from '../../services/url-state.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nimbus-sidebar',
|
||||
@@ -75,13 +76,39 @@ import { VaultService } from '../../../services/vault.service';
|
||||
<span>📁</span>
|
||||
<span>Folders</span>
|
||||
</button>
|
||||
<button *ngIf="open.folders" (click)="urlState.showAllAndReset()" title="Afficher tous les fichiers et réinitialiser la recherche" class="flex items-center gap-1 p-2 rounded hover:bg-surface1 dark:hover:bg-card mr-1">
|
||||
✨
|
||||
</button>
|
||||
<button *ngIf="open.folders" (click)="onCreateFolderAtRoot()" title="Create Folder" class="flex items-center gap-1 p-2 rounded hover:bg-surface1 dark:hover:bg-card">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 -ml-1" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 010 2h-3v3a1 1 0 01-2 0v-3H6a1 1 0 010-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 010 2h-3v3a1 1 0 01-2 0v-3H6a1 1 0 010-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="open.folders" class="px-1 py-1">
|
||||
<div class="flex gap-2 flex-wrap px-2 pb-2">
|
||||
<button type="button" class="px-2 py-1 rounded text-xs"
|
||||
[ngClass]="chipClass(urlState.isKindActive('image'))"
|
||||
(click)="setKind('image')" title="Images">🖼️</button>
|
||||
<button type="button" class="px-2 py-1 rounded text-xs"
|
||||
[ngClass]="chipClass(urlState.isKindActive('video'))"
|
||||
(click)="setKind('video')" title="Vidéos">🎬</button>
|
||||
<button type="button" class="px-2 py-1 rounded text-xs"
|
||||
[ngClass]="chipClass(urlState.isKindActive('pdf'))"
|
||||
(click)="setKind('pdf')" title="PDF">📄</button>
|
||||
<button type="button" class="px-2 py-1 rounded text-xs"
|
||||
[ngClass]="chipClass(urlState.isKindActive('markdown'))"
|
||||
(click)="setKind('markdown')" title="Markdown">📝</button>
|
||||
<button type="button" class="px-2 py-1 rounded text-xs"
|
||||
[ngClass]="chipClass(urlState.isKindActive('excalidraw'))"
|
||||
(click)="setKind('excalidraw')" title="Excalidraw">✏️</button>
|
||||
<button type="button" class="px-2 py-1 rounded text-xs font-mono"
|
||||
[ngClass]="chipClass(urlState.isKindActive('code'))"
|
||||
(click)="setKind('code')" title="Code"></></button>
|
||||
<button type="button" class="px-2 py-1 rounded text-xs"
|
||||
[ngClass]="chipClass(urlState.isKindActive('all'))"
|
||||
(click)="setKind('all')" title="Tout">✨ Tout</button>
|
||||
</div>
|
||||
<app-file-explorer
|
||||
#foldersExplorer
|
||||
[nodes]="effectiveFileTree"
|
||||
@@ -188,8 +215,9 @@ export class NimbusSidebarComponent implements OnChanges {
|
||||
@Output() aboutSelected = new EventEmitter<void>();
|
||||
|
||||
env = environment;
|
||||
open = { quick: true, folders: false, tags: false, trash: false, tests: false };
|
||||
open = { quick: true, folders: true, tags: false, trash: false, tests: false };
|
||||
private vault = inject(VaultService);
|
||||
urlState = inject(UrlStateService);
|
||||
@ViewChild('foldersExplorer') private foldersExplorer?: FileExplorerComponent;
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
@@ -245,4 +273,14 @@ export class NimbusSidebarComponent implements OnChanges {
|
||||
this.folderSelected.emit('.trash');
|
||||
}
|
||||
}
|
||||
|
||||
setKind(kind: 'image'|'video'|'pdf'|'markdown'|'excalidraw'|'code'|'all') {
|
||||
this.urlState.filterByKind(kind);
|
||||
}
|
||||
|
||||
chipClass(active: boolean): string {
|
||||
return active
|
||||
? 'bg-primary/15 text-primary ring-1 ring-primary/40'
|
||||
: 'bg-surface1/50 text-muted hover:bg-surface1 dark:hover:bg-card';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
|
||||
<!-- Fullscreen overlay for note -->
|
||||
<div *ngIf="noteFullScreen && selectedNote && activeView !== 'markdown-playground' && activeView !== 'tests-excalidraw'" class="absolute inset-0 z-50 flex flex-col bg-card dark:bg-main">
|
||||
<div class="note-content-area flex-1 overflow-y-auto px-4 py-4 lg:px-12" appScrollableOverlay>
|
||||
<div class="note-content-area flex-1 overflow-y-auto" appScrollableOverlay>
|
||||
<app-note-viewer
|
||||
[note]="selectedNote || null"
|
||||
[noteHtmlContent]="renderedNoteContent"
|
||||
@@ -170,6 +170,7 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
[folderFilter]="folderFilter"
|
||||
[tagFilter]="tagFilter"
|
||||
[quickLinkFilter]="quickLinkFilter"
|
||||
[kindFilter]="urlState.activeKind()"
|
||||
[query]="listQuery"
|
||||
[selectedId]="selectedNoteId"
|
||||
(openNote)="onOpenNote($event)"
|
||||
@@ -186,7 +187,7 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
|
||||
<!-- Note View + ToC -->
|
||||
<section class="flex-1 relative min-w-0 flex">
|
||||
<div #pageRoot class="note-content-area flex-1 overflow-y-auto px-4 py-4 lg:px-8" appScrollableOverlay>
|
||||
<div #pageRoot class="note-content-area flex-1 overflow-y-auto" appScrollableOverlay>
|
||||
<app-markdown-playground *ngIf="activeView === 'markdown-playground'"></app-markdown-playground>
|
||||
<app-parameters *ngIf="activeView === 'parameters'"></app-parameters>
|
||||
<app-tests-panel *ngIf="activeView === 'tests-panel'"></app-tests-panel>
|
||||
@@ -253,9 +254,9 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
<app-file-explorer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" [foldersOnly]="true" [quickLinkFilter]="quickLinkFilter" (folderSelected)="onFolderSelected($event)" (fileSelected)="onOpenNote($event)"></app-file-explorer>
|
||||
</div>
|
||||
<div [hidden]="mobileNav.activeTab() !== 'list'" class="h-full overflow-y-auto" appScrollableOverlay>
|
||||
<app-notes-list [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [query]="listQuery" [selectedId]="selectedNoteId" (queryChange)="onQueryChange($event)" (openNote)="onOpenNote($event)" (clearQuickLinkFilter)="onClearQuickLinkFilter()" (noteCreated)="onNoteCreated($event)" (noteCreatedAndSelected)="onNoteCreatedAndSelected($event)"></app-notes-list>
|
||||
<app-notes-list [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [kindFilter]="urlState.activeKind()" [query]="listQuery" [selectedId]="selectedNoteId" (queryChange)="onQueryChange($event)" (openNote)="onOpenNote($event)" (clearQuickLinkFilter)="onClearQuickLinkFilter()" (noteCreated)="onNoteCreated($event)" (noteCreatedAndSelected)="onNoteCreatedAndSelected($event)"></app-notes-list>
|
||||
</div>
|
||||
<div [hidden]="mobileNav.activeTab() !== 'page'" class="note-content-area h-full overflow-y-auto px-3 py-4" appScrollableOverlay>
|
||||
<div [hidden]="mobileNav.activeTab() !== 'page'" class="note-content-area h-full overflow-y-auto" appScrollableOverlay>
|
||||
<app-markdown-playground *ngIf="activeView === 'markdown-playground'"></app-markdown-playground>
|
||||
<app-parameters *ngIf="activeView === 'parameters'"></app-parameters>
|
||||
<app-tests-panel *ngIf="activeView === 'tests-panel'"></app-tests-panel>
|
||||
@@ -286,29 +287,29 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
|
||||
@if (mobileNav.activeTab() === 'list') {
|
||||
<div class="h-full flex flex-col overflow-hidden animate-fadeIn">
|
||||
<app-notes-list class="flex-1" [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [query]="listQuery" [selectedId]="selectedNoteId" (queryChange)="onQueryChange($event)" (openNote)="onNoteSelectedMobile($event)" (clearQuickLinkFilter)="onClearQuickLinkFilter()"></app-notes-list>
|
||||
<app-notes-list class="flex-1" [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [kindFilter]="urlState.activeKind()" [query]="listQuery" [selectedId]="selectedNoteId" (queryChange)="onQueryChange($event)" (openNote)="onNoteSelectedMobile($event)" (clearQuickLinkFilter)="onClearQuickLinkFilter()"></app-notes-list>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (mobileNav.activeTab() === 'page') {
|
||||
@if (activeView === 'parameters') {
|
||||
<div class="note-content-area h-full px-3 py-3 animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
<div class="note-content-area h-full animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
<app-parameters></app-parameters>
|
||||
</div>
|
||||
} @else if (activeView === 'markdown-playground') {
|
||||
<div class="note-content-area h-full px-3 py-3 animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
<div class="note-content-area h-full animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
<app-markdown-playground></app-markdown-playground>
|
||||
</div>
|
||||
} @else if (activeView === 'tests-panel') {
|
||||
<div class="note-content-area h-full px-3 py-3 animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
<div class="note-content-area h-full animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
<app-tests-panel></app-tests-panel>
|
||||
</div>
|
||||
} @else if (activeView === 'tests-excalidraw') {
|
||||
<div class="note-content-area h-full px-3 py-3 animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
<div class="note-content-area h-full animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
<app-test-excalidraw-page></app-test-excalidraw-page>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="note-content-area h-full px-3 py-3 animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
<div class="note-content-area h-full animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
@if (selectedNote) {
|
||||
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="onTagSelected($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)" (searchRequested)="openInPageSearch()" (fullScreenRequested)="toggleNoteFullScreen()" (parametersRequested)="onParametersOpen()"></app-note-viewer>
|
||||
} @else {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable, Provider } from '@angular/core';
|
||||
import { FileTypeDetectorService } from '../../services/file-type-detector.service';
|
||||
|
||||
export type ViewerKind = 'markdown' | 'excalidraw' | 'image' | 'video' | 'pdf' | 'code' | 'text' | 'unknown';
|
||||
|
||||
export interface FileViewerEntry {
|
||||
kind: ViewerKind;
|
||||
/** Optional dynamic component loader; not required by current SmartFileViewer strategy */
|
||||
load?: () => Promise<any>;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FileViewerRegistry {
|
||||
constructor(private readonly detector: FileTypeDetectorService) {}
|
||||
|
||||
getViewerKind(path: string, content: string): ViewerKind {
|
||||
return this.detector.getViewerType(path, content) as ViewerKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional registry map for future plugin-style viewers. Not used by current UI, but available.
|
||||
*/
|
||||
getDefaultEntries(): Record<ViewerKind, FileViewerEntry> {
|
||||
return {
|
||||
markdown: { kind: 'markdown', load: async () => (await import('../../components/markdown-viewer/markdown-viewer.component')).MarkdownViewerComponent },
|
||||
excalidraw: { kind: 'excalidraw', load: async () => (await import('../features/drawings/drawings-editor.component')).DrawingsEditorComponent },
|
||||
image: { kind: 'image' },
|
||||
video: { kind: 'video' },
|
||||
pdf: { kind: 'pdf' },
|
||||
code: { kind: 'code', load: async () => (await import('../features/code-viewer/code-viewer.component')).CodeViewerComponent },
|
||||
text: { kind: 'text' },
|
||||
unknown: { kind: 'unknown' }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to register viewer-related providers. Current services are providedIn: 'root',
|
||||
* so this returns an empty list to keep bootstrap code explicit and future-proof.
|
||||
*/
|
||||
export function provideViewers(): Provider[] {
|
||||
return [];
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export interface UrlState {
|
||||
folder?: string; // Dossier de filtrage
|
||||
quick?: string; // Quick link de filtrage
|
||||
search?: string; // Terme de recherche
|
||||
kind?: 'image' | 'video' | 'pdf' | 'markdown' | 'excalidraw' | 'code' | 'all';
|
||||
}
|
||||
|
||||
export interface UrlStateChangeEvent {
|
||||
@@ -120,6 +121,15 @@ export class UrlStateService implements OnDestroy {
|
||||
return search;
|
||||
});
|
||||
|
||||
/**
|
||||
* Filtre de type de fichier actif
|
||||
*/
|
||||
readonly activeKind = computed(() => {
|
||||
const k = this.currentStateSignal().kind || null;
|
||||
console.log('🧩 activeKind():', k);
|
||||
return k;
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// CONSTRUCTOR & LIFECYCLE
|
||||
// ========================================
|
||||
@@ -138,6 +148,7 @@ export class UrlStateService implements OnDestroy {
|
||||
const v = qpm.get(k);
|
||||
if (v !== null) params[k] = v;
|
||||
}
|
||||
|
||||
// Fallback: at first load with certain servers, Router may expose empty params
|
||||
if (Object.keys(params).length === 0 && typeof window !== 'undefined') {
|
||||
const sp = new URLSearchParams(window.location.search || '');
|
||||
@@ -164,6 +175,26 @@ export class UrlStateService implements OnDestroy {
|
||||
this.previousStateSignal.set(previousState);
|
||||
this.currentStateSignal.set(newState);
|
||||
this.stateChangeSubject.next({ previous: previousState, current: newState, changed });
|
||||
|
||||
// If the active list section changed (tag/folder/quick), normalize URL:
|
||||
// - Keep only the new section key
|
||||
// - Clear others (including search), reset kind to 'all', and clear opened note
|
||||
const prevSection = this.getActiveSection(previousState);
|
||||
const nextSection = this.getActiveSection(newState);
|
||||
if (prevSection !== nextSection && nextSection !== null) {
|
||||
// Schedule to avoid interfering within the same router tick
|
||||
setTimeout(() => {
|
||||
const partial: Partial<UrlState> = {
|
||||
tag: nextSection === 'tag' ? newState.tag! : null,
|
||||
folder: nextSection === 'folder' ? newState.folder! : null,
|
||||
quick: nextSection === 'quick' ? newState.quick! : null,
|
||||
search: null,
|
||||
kind: 'all',
|
||||
note: null,
|
||||
};
|
||||
this.updateUrl(partial);
|
||||
}, 0);
|
||||
}
|
||||
} else if (!previousState || Object.keys(previousState).length === 0) {
|
||||
// Première initialisation si nécessaire
|
||||
console.log('🌐 UrlStateService - first initialization');
|
||||
@@ -233,6 +264,16 @@ export class UrlStateService implements OnDestroy {
|
||||
console.log('✅ parseUrlParams - search set:', state.search);
|
||||
}
|
||||
|
||||
// Kind (toujours en plus)
|
||||
if (params['kind']) {
|
||||
const raw = decodeURIComponent(params['kind']).toLowerCase();
|
||||
const allowed = ['image','video','pdf','markdown','excalidraw','code','all'];
|
||||
if (allowed.includes(raw)) {
|
||||
state.kind = raw as UrlState['kind'];
|
||||
console.log('✅ parseUrlParams - kind set:', state.kind);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎯 parseUrlParams final state:', state);
|
||||
return state;
|
||||
}
|
||||
@@ -243,7 +284,7 @@ export class UrlStateService implements OnDestroy {
|
||||
private detectChanges(previous: UrlState, current: UrlState): (keyof UrlState)[] {
|
||||
const changed: (keyof UrlState)[] = [];
|
||||
|
||||
const keys: (keyof UrlState)[] = ['note', 'tag', 'folder', 'quick', 'search'];
|
||||
const keys: (keyof UrlState)[] = ['note', 'tag', 'folder', 'quick', 'search', 'kind'];
|
||||
|
||||
for (const key of keys) {
|
||||
if (previous[key] !== current[key]) {
|
||||
@@ -254,6 +295,16 @@ export class UrlStateService implements OnDestroy {
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la section active parmi tag/folder/quick, sinon null
|
||||
*/
|
||||
private getActiveSection(state: UrlState): 'tag' | 'folder' | 'quick' | null {
|
||||
if (state.tag) return 'tag';
|
||||
if (state.folder) return 'folder';
|
||||
if (state.quick) return 'quick';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// STATE UPDATES
|
||||
// ========================================
|
||||
@@ -348,6 +399,14 @@ export class UrlStateService implements OnDestroy {
|
||||
await this.updateUrl({ search: searchTerm || undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtrer par type de fichier (image, video, pdf, markdown, excalidraw, code, all)
|
||||
*/
|
||||
async filterByKind(kind: UrlState['kind']): Promise<void> {
|
||||
const normalized = (kind || 'all');
|
||||
await this.updateUrl({ kind: normalized });
|
||||
}
|
||||
|
||||
/**
|
||||
* Définir la note et optionnellement le dossier (pour création de note)
|
||||
* Utilise merge pour conserver les autres paramètres (search, etc.)
|
||||
@@ -418,6 +477,7 @@ export class UrlStateService implements OnDestroy {
|
||||
if (stateToShare.folder) params.set('folder', stateToShare.folder);
|
||||
if (stateToShare.quick) params.set('quick', stateToShare.quick);
|
||||
if (stateToShare.search) params.set('search', stateToShare.search);
|
||||
if (stateToShare.kind) params.set('kind', stateToShare.kind);
|
||||
|
||||
const baseUrl = window.location.origin + window.location.pathname;
|
||||
return `${baseUrl}?${params.toString()}`;
|
||||
@@ -436,6 +496,13 @@ export class UrlStateService implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show all files and clear contextual filters/search so the next search applies globally.
|
||||
*/
|
||||
async showAllAndReset(): Promise<void> {
|
||||
await this.updateUrl({ tag: null, folder: null, quick: null, search: null, kind: 'all', note: null });
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// OBSERVABLES & EVENTS
|
||||
// ========================================
|
||||
@@ -488,6 +555,13 @@ export class UrlStateService implements OnDestroy {
|
||||
return this.currentStateSignal().quick === quickLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier si un type est actif
|
||||
*/
|
||||
isKindActive(kind: UrlState['kind']): boolean {
|
||||
return (this.currentStateSignal().kind || 'all') === (kind || 'all');
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir l'état actuel (snapshot)
|
||||
*/
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Component, Input, OnChanges, SimpleChanges, signal, computed, inject, CUSTOM_ELEMENTS_SCHEMA, ViewContainerRef, ComponentRef, ViewChild, effect } from '@angular/core';
|
||||
import { Component, Input, OnChanges, SimpleChanges, signal, computed, inject, CUSTOM_ELEMENTS_SCHEMA, ViewContainerRef, ComponentRef, ViewChild, effect, Output, EventEmitter } 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';
|
||||
import { EditorStateService } from '../../services/editor-state.service';
|
||||
import { DrawingsEditorComponent } from '../../app/features/drawings/drawings-editor.component';
|
||||
import { PdfViewerComponent } from '../../app/features/note-view/components/pdf-viewer/pdf-viewer.component';
|
||||
import { CodeRendererComponent } from '../../app/features/note-view/components/code-renderer/code-renderer.component';
|
||||
import { ImageViewerComponent } from '../../app/features/note-view/components/image-viewer/image-viewer.component';
|
||||
import { VideoPlayerComponent } from '../../app/features/note-view/components/video-player/video-player.component';
|
||||
|
||||
/**
|
||||
* Composant intelligent qui détecte automatiquement le type de fichier
|
||||
@@ -22,7 +27,7 @@ import { EditorStateService } from '../../services/editor-state.service';
|
||||
@Component({
|
||||
selector: 'app-smart-file-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MarkdownViewerComponent],
|
||||
imports: [CommonModule, MarkdownViewerComponent, DrawingsEditorComponent, PdfViewerComponent, CodeRendererComponent, ImageViewerComponent, VideoPlayerComponent],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
template: `
|
||||
<div class="smart-file-viewer" [attr.data-viewer-type]="viewerType()">
|
||||
@@ -31,41 +36,53 @@ import { EditorStateService } from '../../services/editor-state.service';
|
||||
|
||||
<!-- Markdown/Excalidraw Viewer (Read Mode) -->
|
||||
<app-markdown-viewer
|
||||
*ngIf="!isEditMode() && (viewerType() === 'markdown' || viewerType() === 'excalidraw')"
|
||||
*ngIf="!isEditMode() && viewerType() === 'markdown'"
|
||||
[content]="content"
|
||||
[allNotes]="allNotes"
|
||||
[currentNote]="currentNote"
|
||||
[showToolbar]="showToolbar"
|
||||
[fullscreenMode]="fullscreenMode"
|
||||
[filePath]="filePath"
|
||||
(editModeRequested)="onEditModeRequested($event)">
|
||||
(editModeRequested)="onEditModeRequested($event)"
|
||||
class="animate-fadeIn">
|
||||
</app-markdown-viewer>
|
||||
|
||||
<!-- Excalidraw Viewer -->
|
||||
<div *ngIf="!isEditMode() && viewerType() === 'excalidraw'" class="smart-file-viewer__excalidraw animate-fadeIn">
|
||||
<app-drawings-editor
|
||||
class="w-full h-full flex-1 min-h-0"
|
||||
[path]="filePath"
|
||||
[showInlineActions]="true"
|
||||
></app-drawings-editor>
|
||||
</div>
|
||||
|
||||
<!-- 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 *ngIf="viewerType() === 'image'" class="smart-file-viewer__image animate-fadeIn">
|
||||
<app-image-viewer [src]="imageSrc()" [alt]="fileName()" (dimensions)="onImgDims($event)"></app-image-viewer>
|
||||
</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 *ngIf="viewerType() === 'pdf'" class="smart-file-viewer__pdf animate-fadeIn">
|
||||
<app-pdf-viewer class="w-full h-full flex-1 min-h-0" [src]="pdfSrc()" [path]="filePath"></app-pdf-viewer>
|
||||
</div>
|
||||
|
||||
<!-- Video Viewer -->
|
||||
<div *ngIf="viewerType() === 'video'" class="smart-file-viewer__video animate-fadeIn">
|
||||
<app-video-player [src]="videoSrc()" (metadata)="onVidMeta($event)"></app-video-player>
|
||||
</div>
|
||||
|
||||
<!-- Code Viewer -->
|
||||
<div *ngIf="viewerType() === 'code'" class="smart-file-viewer__text animate-fadeIn w-full h-full">
|
||||
<app-code-renderer [path]="filePath" [content]="resolvedContent()" />
|
||||
</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 *ngIf="viewerType() === 'text'" class="smart-file-viewer__text animate-fadeIn">
|
||||
<pre class="p-4 bg-card rounded-lg overflow-auto"><code>{{ resolvedContent() }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- Unknown File Type -->
|
||||
<div *ngIf="viewerType() === 'unknown'" class="smart-file-viewer__unknown">
|
||||
<div *ngIf="viewerType() === 'unknown'" class="smart-file-viewer__unknown animate-fadeIn">
|
||||
<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"/>
|
||||
@@ -80,12 +97,17 @@ import { EditorStateService } from '../../services/editor-state.service';
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.smart-file-viewer {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.smart-file-viewer__editor {
|
||||
@@ -105,14 +127,42 @@ import { EditorStateService } from '../../services/editor-state.service';
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Excalidraw should stretch exactly like PDF */
|
||||
.smart-file-viewer__excalidraw {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.smart-file-viewer__excalidraw app-drawings-editor,
|
||||
.smart-file-viewer__pdf app-pdf-viewer {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.smart-file-viewer__pdf {
|
||||
padding: 0;
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.smart-file-viewer__video {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.smart-file-viewer__image img {
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.smart-file-viewer__pdf iframe {
|
||||
min-height: 600px;
|
||||
}
|
||||
/* Removed min-height on PDF iframe to let it stretch with flex */
|
||||
|
||||
.smart-file-viewer__text pre {
|
||||
width: 100%;
|
||||
@@ -125,11 +175,14 @@ import { EditorStateService } from '../../services/editor-state.service';
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.smart-file-viewer__image,
|
||||
.smart-file-viewer__pdf,
|
||||
.smart-file-viewer__text,
|
||||
.smart-file-viewer__unknown {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.smart-file-viewer__pdf {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
@@ -139,42 +192,62 @@ export class SmartFileViewerComponent implements OnChanges {
|
||||
private fileTypeDetector = inject(FileTypeDetectorService);
|
||||
private editorStateService = inject(EditorStateService);
|
||||
private editorComponentRef?: ComponentRef<any>;
|
||||
private codeComponentRef?: ComponentRef<any>;
|
||||
|
||||
@Input() filePath: string = '';
|
||||
@Input() content: string = '';
|
||||
private filePathSig = signal<string>('');
|
||||
private contentSig = signal<string>('');
|
||||
@Input() allNotes: Note[] = [];
|
||||
@Input() currentNote?: Note;
|
||||
@Input() showToolbar: boolean = true;
|
||||
@Input() fullscreenMode: boolean = true;
|
||||
@Output() imageDimensions = new EventEmitter<{ width: number; height: number }>();
|
||||
@Output() videoMetadata = new EventEmitter<{ width: number; height: number; duration: number }>();
|
||||
|
||||
viewerType = signal<'markdown' | 'excalidraw' | 'image' | 'pdf' | 'text' | 'unknown'>('unknown');
|
||||
viewerType = signal<'markdown' | 'excalidraw' | 'image' | 'pdf' | 'video' | 'code' | 'text' | 'unknown'>('unknown');
|
||||
isEditMode = computed(() => this.editorStateService.isEditMode());
|
||||
|
||||
fileName = computed(() => {
|
||||
return this.filePath.split('/').pop() || this.filePath.split('\\').pop() || 'Unknown file';
|
||||
const p = this.filePathSig();
|
||||
return p.split('/').pop() || p.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;
|
||||
const c = this.contentSig();
|
||||
if (c.startsWith('data:image')) {
|
||||
return c;
|
||||
}
|
||||
|
||||
// Otherwise, construct API path
|
||||
return `/api/files/${encodeURIComponent(this.filePath)}`;
|
||||
// Otherwise, serve directly from vault static path
|
||||
return `/vault/${encodeURI(this.filePathSig())}`;
|
||||
});
|
||||
|
||||
pdfSrc = computed(() => {
|
||||
if (this.viewerType() !== 'pdf') return '';
|
||||
return `/api/files/${encodeURIComponent(this.filePath)}`;
|
||||
const path = encodeURI(this.filePathSig());
|
||||
return `/vault/${path}#view=FitH&zoom=page-width`;
|
||||
});
|
||||
|
||||
videoSrc = computed(() => {
|
||||
if (this.viewerType() !== 'video') return '';
|
||||
return `/vault/${encodeURI(this.filePathSig())}`;
|
||||
});
|
||||
|
||||
// Internal fetched content for non-markdown text/code files
|
||||
private fetchedContent = signal<string>('');
|
||||
resolvedContent = computed(() => {
|
||||
const c = this.contentSig();
|
||||
return (c && c.length > 0) ? c : this.fetchedContent();
|
||||
});
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['filePath'] || changes['content']) {
|
||||
this.detectViewerType();
|
||||
}
|
||||
if (changes['filePath']) this.filePathSig.set(this.filePath || '');
|
||||
if (changes['content']) this.contentSig.set(this.content || '');
|
||||
if (changes['filePath'] || changes['content']) this.detectViewerType();
|
||||
}
|
||||
|
||||
private detectViewerType(): void {
|
||||
@@ -192,6 +265,29 @@ export class SmartFileViewerComponent implements OnChanges {
|
||||
this.unloadEditor();
|
||||
}
|
||||
});
|
||||
|
||||
// No-op for code lazy loading: now using inline CodeRendererComponent
|
||||
|
||||
// Fetch textual content for code/text viewers when filePath or viewer changes
|
||||
effect(() => {
|
||||
const vt = this.viewerType();
|
||||
const path = this.filePathSig();
|
||||
if (!path) { this.fetchedContent.set(''); return; }
|
||||
if (vt === 'code' || vt === 'text') {
|
||||
// Do not attempt to fetch files inside the .obsidian folder (restricted)
|
||||
if (path.startsWith('.obsidian/')) {
|
||||
this.fetchedContent.set('');
|
||||
} else {
|
||||
const url = `/vault/${encodeURI(path)}`;
|
||||
fetch(url)
|
||||
.then(r => r.text().catch(() => ''))
|
||||
.then(txt => this.fetchedContent.set(txt))
|
||||
.catch(() => this.fetchedContent.set(''));
|
||||
}
|
||||
} else {
|
||||
this.fetchedContent.set('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async onEditModeRequested(event: { path: string; content: string }): Promise<void> {
|
||||
@@ -241,4 +337,8 @@ export class SmartFileViewerComponent implements OnChanges {
|
||||
|
||||
console.log('[SmartFileViewer] Editor unloaded');
|
||||
}
|
||||
|
||||
// Bridge child viewer events to parent consumers
|
||||
onImgDims(ev: { width: number; height: number }) { this.imageDimensions.emit(ev); }
|
||||
onVidMeta(ev: { width: number; height: number; duration: number }) { this.videoMetadata.emit(ev); }
|
||||
}
|
||||
|
||||
@@ -1,56 +1,24 @@
|
||||
@if(note(); as note) {
|
||||
<div class="mx-auto flex max-w-7xl">
|
||||
<div class="flex-grow min-w-0 max-w-4xl px-6 py-8 lg:px-12 lg:py-12">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-4xl font-bold text-text-main mb-2">{{ note.title }}</h1>
|
||||
<div class="text-sm text-text-muted">
|
||||
<span>Mise à jour : {{ note.updatedAt | date:'medium' }}</span>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@for(tag of note.tags; track tag) {
|
||||
<span class="chip text-sm">{{ tag }}</span>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex flex-col w-full h-full">
|
||||
@if(note.type === 'pdf') {
|
||||
<app-pdf-viewer
|
||||
class="flex-1 w-full h-full"
|
||||
[src]="note.filePath">
|
||||
</app-pdf-viewer>
|
||||
} @else {
|
||||
<div class="mx-auto flex max-w-7xl w-full h-full">
|
||||
<div class="flex-grow min-w-0 max-w-4xl px-6 py-8 lg:px-12 lg:py-12">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-4xl font-bold text-text-main mb-2">{{ note.title }}</h1>
|
||||
<div class="text-sm text-text-muted">
|
||||
<span>Mise à jour : {{ note.updatedAt | date:'medium' }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<article class="prose prose-lg prose-headings:text-text-main prose-p:text-text-main max-w-none dark:prose-invert" [innerHTML]="noteHtmlContent()">
|
||||
</article>
|
||||
|
||||
@if (note.backlinks.length > 0) {
|
||||
<footer class="mt-12 border-t border-border pt-6">
|
||||
<h2 class="mb-4 text-xl font-semibold text-text-main">Backlinks</h2>
|
||||
<ul class="space-y-2">
|
||||
@for (backlinkId of note.backlinks; track backlinkId) {
|
||||
<li>
|
||||
<button (click)="noteLinkClicked.emit(backlinkId)" class="text-sm font-medium text-accent hover:underline">
|
||||
{{ formatBacklinkId(backlinkId) }}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</footer>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (tableOfContents().length > 0) {
|
||||
<aside class="hidden w-64 flex-shrink-0 lg:block">
|
||||
<div class="sticky top-0 h-screen overflow-y-auto px-4 pt-8 lg:pt-12">
|
||||
<nav class="rounded-xl border border-border bg-card p-4 shadow-subtle">
|
||||
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wide text-text-muted">Dans cette page</h3>
|
||||
<ul class="space-y-1">
|
||||
@for(item of tableOfContents(); track item.id) {
|
||||
<li>
|
||||
<button (click)="scrollToHeading(item.id)"
|
||||
[style.padding-left.rem]="(item.level - 1) * 1"
|
||||
class="block w-full rounded-lg py-1 text-left text-sm text-text-muted transition-colors hover:text-text-main focus:outline-none focus:text-text-main">
|
||||
{{ item.text }}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
<article class="prose prose-lg max-w-none dark:prose-invert"
|
||||
[innerHTML]="noteHtmlContent()"></article>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import { Note } from '../../../types';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { NotePreviewService, PreviewData } from '../../../services/note-preview.service';
|
||||
import { NoteHeaderComponent } from '../../../app/features/note/components/note-header/note-header.component';
|
||||
import { SmartFileViewerComponent } from '../../smart-file-viewer/smart-file-viewer.component';
|
||||
import { FileTypeDetectorService } from '../../../services/file-type-detector.service';
|
||||
import { MarkdownEditorComponent } from '../../../app/features/editor/markdown-editor.component';
|
||||
import { EditorStateService } from '../../../services/editor-state.service';
|
||||
import { ClipboardService } from '../../../app/shared/services/clipboard.service';
|
||||
@@ -45,10 +47,18 @@ export interface WikiLinkActivation {
|
||||
@Component({
|
||||
selector: 'app-note-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NoteHeaderComponent, MarkdownEditorComponent],
|
||||
imports: [CommonModule, NoteHeaderComponent, MarkdownEditorComponent, SmartFileViewerComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [`
|
||||
.note-viewer-root { position: relative; border-radius: 0.5rem; overflow: visible; }
|
||||
.note-viewer-root {
|
||||
position: relative;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
height: 100%; /* inherit height from parent section (app shell controls layout) */
|
||||
min-height: 0;
|
||||
}
|
||||
.note-viewer-root::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -66,275 +76,297 @@ export interface WikiLinkActivation {
|
||||
z-index: 0;
|
||||
}
|
||||
.note-viewer-root > * { position: relative; z-index: 1; }
|
||||
.note-viewer-root app-smart-file-viewer { flex: 1 1 auto; min-height: 0; display: flex; }
|
||||
|
||||
.note-viewer-scroll {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
`],
|
||||
template: `
|
||||
<div class="note-viewer-root relative px-1 pb-1 pt-0 prose prose-lg dark:prose-invert max-w-none prose-p:leading-[1] prose-li:leading-[1] prose-blockquote:leading-[1] rounded-md">
|
||||
<div class="note-viewer-root relative rounded-md">
|
||||
<div class="sr-only" role="status" aria-live="polite">{{ copyStatus() }}</div>
|
||||
|
||||
<ng-container *ngIf="note() as note">
|
||||
<!-- Compact Top Bar -->
|
||||
<div class="flex items-start justify-between gap-2 pl-1 pr-2 py-1 mb-2 text-text-muted text-xs">
|
||||
<app-note-header class="flex-1 min-w-0"
|
||||
[fullPath]="note.filePath"
|
||||
[noteId]="note.id"
|
||||
[tags]="note.tags ?? []"
|
||||
(copyRequested)="copyPath()"
|
||||
(openDirectory)="directoryClicked.emit(getDirectoryFromPath(note.filePath))"
|
||||
(noteMoved)="onNoteMoved($event)"
|
||||
(tagsChange)="onTagsChange($event)"
|
||||
(tagSelected)="tagClicked.emit($event)"
|
||||
></app-note-header>
|
||||
<div class="note-viewer-scroll">
|
||||
<!-- Compact Top Bar -->
|
||||
<div class="not-prose flex items-start justify-between gap-2 px-4 py-4 lg:px-8 mb-2 text-text-muted text-xs">
|
||||
<app-note-header class="flex-1 min-w-0"
|
||||
[fullPath]="note.filePath"
|
||||
[noteId]="note.id"
|
||||
[tags]="note.tags ?? []"
|
||||
(copyRequested)="copyPath()"
|
||||
(openDirectory)="directoryClicked.emit(getDirectoryFromPath(note.filePath))"
|
||||
(noteMoved)="onNoteMoved($event)"
|
||||
(tagsChange)="onTagsChange($event)"
|
||||
(tagSelected)="tagClicked.emit($event)"
|
||||
></app-note-header>
|
||||
|
||||
<div class="flex items-center gap-1 self-start pt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="enterEdit()"
|
||||
title="Éditer"
|
||||
aria-label="Éditer"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="fullScreenRequested.emit()"
|
||||
[attr.title]="fullScreenActive() ? 'Exit full screen' : 'Open in full screen'"
|
||||
[attr.aria-label]="fullScreenActive() ? 'Exit full screen' : 'Open in full screen'"
|
||||
>
|
||||
{{ fullScreenActive() ? '⤢' : '⤢' }}
|
||||
</button>
|
||||
|
||||
<div class="hidden sm:block">
|
||||
<div class="flex items-center gap-1 self-start pt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="showToc.emit()"
|
||||
[attr.title]="tocOpen() ? 'Cacher sommaire' : 'Afficher sommaire'"
|
||||
[attr.aria-label]="tocOpen() ? 'Cacher sommaire' : 'Afficher sommaire'"
|
||||
(click)="enterEdit()"
|
||||
title="Éditer"
|
||||
aria-label="Éditer"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" [ngClass]="tocOpen() ? 'toc-toggle--active' : 'toc-toggle--idle'"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="M13 8h5"/><path d="M13 12h5"/><path d="M13 16h5"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="fullScreenRequested.emit()"
|
||||
[attr.title]="fullScreenActive() ? 'Exit full screen' : 'Open in full screen'"
|
||||
[attr.aria-label]="fullScreenActive() ? 'Exit full screen' : 'Open in full screen'"
|
||||
>
|
||||
{{ fullScreenActive() ? '⤢' : '⤢' }}
|
||||
</button>
|
||||
|
||||
<div class="hidden sm:block">
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="showToc.emit()"
|
||||
[attr.title]="tocOpen() ? 'Cacher sommaire' : 'Afficher sommaire'"
|
||||
[attr.aria-label]="tocOpen() ? 'Cacher sommaire' : 'Afficher sommaire'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" [ngClass]="tocOpen() ? 'toc-toggle--active' : 'toc-toggle--idle'"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="M13 8h5"/><path d="M13 12h5"/><path d="M13 16h5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="searchRequested.emit()"
|
||||
title="Rechercher"
|
||||
aria-label="Rechercher">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="copyMarkdown()"
|
||||
aria-label="Copier tout le markdown"
|
||||
title="Copier tout le markdown">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M20 7v11a2 2 0 0 1-2 2H9"/><path d="M8 7h8"/><path d="M8 11h8"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="toggleMenu()"
|
||||
aria-haspopup="menu"
|
||||
[attr.aria-expanded]="menuOpen()"
|
||||
title="More options"
|
||||
aria-label="More options"
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
@if (menuOpen()) {
|
||||
<div class="absolute right-0 mt-2 w-56 rounded-xl border border-border bg-card shadow-subtle not-prose z-10 overflow-hidden">
|
||||
<button type="button" class="block w-full text-left px-3 py-2 text-sm transition-colors hover:bg-[rgba(99,102,241,0.12)] dark:hover:bg-[rgba(99,102,241,0.28)] focus-visible:bg-[rgba(99,102,241,0.16)] dark:focus-visible:bg-[rgba(99,102,241,0.35)]" (click)="parametersRequested.emit(); closeMenu()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="inline h-4 w-4 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M12 1v6m0 6v6m-6-6h6m6 0h-6M4.93 4.93l4.24 4.24m5.66 5.66l4.24 4.24m-4.24-14.14l4.24-4.24M4.93 19.07l4.24-4.24"/>
|
||||
</svg>
|
||||
Parameters
|
||||
</button>
|
||||
<button type="button" class="block w-full text-left px-3 py-2 text-sm transition-colors hover:bg-[rgba(99,102,241,0.12)] dark:hover:bg-[rgba(99,102,241,0.28)] focus-visible:bg-[rgba(99,102,241,0.16)] dark:focus-visible:bg-[rgba(99,102,241,0.35)]" (click)="legacyRequested.emit(); closeMenu()">🔧 Legacy</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="searchRequested.emit()"
|
||||
title="Rechercher"
|
||||
aria-label="Rechercher">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="copyMarkdown()"
|
||||
aria-label="Copier tout le markdown"
|
||||
title="Copier tout le markdown">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M20 7v11a2 2 0 0 1-2 2H9"/><path d="M8 7h8"/><path d="M8 11h8"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="toggleMenu()"
|
||||
aria-haspopup="menu"
|
||||
[attr.aria-expanded]="menuOpen()"
|
||||
title="More options"
|
||||
aria-label="More options"
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
@if (menuOpen()) {
|
||||
<div class="absolute right-0 mt-2 w-56 rounded-xl border border-border bg-card shadow-subtle not-prose z-10 overflow-hidden">
|
||||
<button type="button" class="block w-full text-left px-3 py-2 text-sm transition-colors hover:bg-[rgba(99,102,241,0.12)] dark:hover:bg-[rgba(99,102,241,0.28)] focus-visible:bg-[rgba(99,102,241,0.16)] dark:focus-visible:bg-[rgba(99,102,241,0.35)]" (click)="parametersRequested.emit(); closeMenu()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="inline h-4 w-4 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M12 1v6m0 6v6m-6-6h6m6 0h-6M4.93 4.93l4.24 4.24m5.66 5.66l4.24 4.24m-4.24-14.14l4.24-4.24M4.93 19.07l4.24-4.24"/>
|
||||
</svg>
|
||||
Parameters
|
||||
@if (isEditMode()) {
|
||||
<app-markdown-editor
|
||||
[initialPath]="note.filePath"
|
||||
[initialContent]="note.rawContent ?? note.content"
|
||||
/>
|
||||
} @else {
|
||||
@if (frontmatterTags().length > 0) {
|
||||
<div class="px-4 py-4 lg:px-8 mb-6 md-tag-group not-prose">
|
||||
@for (tag of frontmatterTags(); track tag) {
|
||||
<button
|
||||
type="button"
|
||||
class="md-tag-badge"
|
||||
[ngClass]="tagColorClass(tag)"
|
||||
data-origin="frontmatter"
|
||||
[attr.data-tag]="tag"
|
||||
(click)="tagClicked.emit(tag)">
|
||||
{{ tag }}
|
||||
</button>
|
||||
<button type="button" class="block w-full text-left px-3 py-2 text-sm transition-colors hover:bg-[rgba(99,102,241,0.12)] dark:hover:bg-[rgba(99,102,241,0.28)] focus-visible:bg-[rgba(99,102,241,0.16)] dark:focus-visible:bg-[rgba(99,102,241,0.35)]" (click)="legacyRequested.emit(); closeMenu()">🔧 Legacy</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="px-4 lg:px-8 not-prose flex flex-col gap-2 text-sm text-text-muted my-4">
|
||||
<!-- Row 1: date + author -->
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{{ note.updatedAt | date:'medium' }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM5.5 21a6.5 6.5 0 0113 0" />
|
||||
</svg>
|
||||
{{ getAuthorFromFrontmatter() ?? note.author ?? 'Auteur inconnu' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: state icons (toggle buttons) -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
@if (hasState('favoris')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('favoris') ? 'text-rose-500' : 'text-muted'" title="{{ state('favoris') ? 'Ajouté aux favoris' : 'Non favori' }}" aria-label="{{ state('favoris') ? 'Ajouté aux favoris' : 'Non favori' }}" (click)="toggleState('favoris')">
|
||||
@if (state('favoris')) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||
<path d="M19 14.5c1.5-1.5 2-4 0-6s-5-2-7 1c-2-3-5-3.5-7-1s-1.5 4.5 0 6l7 6z" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 14.5c1.5-1.5 2-4 0-6s-5-2-7 1c-2-3-5-3.5-7-1s-1.5 4.5 0 6l7 6z" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('publish')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('publish') ? 'text-green-500' : 'text-muted'" title="{{ state('publish') ? 'Publié sur le web' : 'Non publié' }}" aria-label="{{ state('publish') ? 'Publié sur le web' : 'Non publié' }}" (click)="toggleState('publish')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
@if (hasState('draft')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('draft') ? 'text-yellow-500' : 'text-muted'" title="{{ state('draft') ? 'Brouillon actif' : 'Pas un brouillon' }}" aria-label="{{ state('draft') ? 'Brouillon actif' : 'Pas un brouillon' }}" (click)="toggleState('draft')">
|
||||
@if (state('draft')) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3h18v4H3z" />
|
||||
<path d="M6 7v14h12V7" />
|
||||
<path d="M9 12h6" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('template')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('template') ? 'text-amber-500' : 'text-muted'" title="{{ state('template') ? 'Modèle' : 'Non modèle' }}" aria-label="{{ state('template') ? 'Modèle' : 'Non modèle' }}" (click)="toggleState('template')">
|
||||
@if (state('template')) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="14" rx="2" />
|
||||
<path d="M7 8h10" />
|
||||
<path d="M7 12h6" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="5" width="18" height="12" rx="2" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('task')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('task') ? 'text-indigo-500' : 'text-muted'" title="{{ state('task') ? 'Tâche' : 'Pas une tâche' }}" aria-label="{{ state('task') ? 'Tâche' : 'Pas une tâche' }}" (click)="toggleState('task')">
|
||||
@if (state('task')) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<path d="M8 12l2 2 4-4" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('private')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('private') ? 'text-purple-500' : 'text-muted'" title="{{ state('private') ? 'Privé' : 'Public' }}" aria-label="{{ state('private') ? 'Privé' : 'Public' }}" (click)="toggleState('private')">
|
||||
@if (state('private')) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('archive')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('archive') ? 'text-amber-600' : 'text-muted'" title="{{ state('archive') ? 'Document archivé' : 'Document non archivé' }}" aria-label="{{ state('archive') ? 'Document archivé' : 'Document non archivé' }}" (click)="toggleState('archive')">
|
||||
@if (state('archive')) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="22,12 18,12 18,8"/>
|
||||
<path d="M18,2H6a2,2,0,0,0-2,2V22a2,2,0,0,0,2,2H18a2,2,0,0,0,2-2V8Z"/>
|
||||
<line x1="10" y1="12" x2="14" y2="12"/>
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 8v13 a2 2 0 01-2 2H5a2 2 0 01-2-2V8"/>
|
||||
<path d="M3 4h18l-2-2H5l-2 2z"/>
|
||||
<line x1="10" y1="12" x2="14" y2="12"/>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isEditMode()) {
|
||||
<app-markdown-editor
|
||||
[initialPath]="note.filePath"
|
||||
[initialContent]="note.rawContent ?? note.content"
|
||||
/>
|
||||
} @else {
|
||||
|
||||
@if (frontmatterTags().length > 0) {
|
||||
<div class="mb-6 md-tag-group not-prose">
|
||||
@for (tag of frontmatterTags(); track tag) {
|
||||
<button
|
||||
type="button"
|
||||
class="md-tag-badge"
|
||||
[ngClass]="tagColorClass(tag)"
|
||||
data-origin="frontmatter"
|
||||
[attr.data-tag]="tag"
|
||||
(click)="tagClicked.emit(tag)">
|
||||
{{ tag }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="not-prose flex flex-col gap-2 text-sm text-text-muted my-4">
|
||||
<!-- Row 1: date + author -->
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{{ note.updatedAt | date:'medium' }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM5.5 21a6.5 6.5 0 0113 0" />
|
||||
</svg>
|
||||
{{ getAuthorFromFrontmatter() ?? note.author ?? 'Auteur inconnu' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: state icons (toggle buttons) -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
@if (hasState('favoris')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('favoris') ? 'text-rose-500' : 'text-muted'" title="{{ state('favoris') ? 'Ajouté aux favoris' : 'Non favori' }}" aria-label="{{ state('favoris') ? 'Ajouté aux favoris' : 'Non favori' }}" (click)="toggleState('favoris')">
|
||||
@if (state('favoris')) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||
<path d="M19 14.5c1.5-1.5 2-4 0-6s-5-2-7 1c-2-3-5-3.5-7-1s-1.5 4.5 0 6l7 6z" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 14.5c1.5-1.5 2-4 0-6s-5-2-7 1c-2-3-5-3.5-7-1s-1.5 4.5 0 6l7 6z" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('publish')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('publish') ? 'text-green-500' : 'text-muted'" title="{{ state('publish') ? 'Publié sur le web' : 'Non publié' }}" aria-label="{{ state('publish') ? 'Publié sur le web' : 'Non publié' }}" (click)="toggleState('publish')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
@if (hasState('draft')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('draft') ? 'text-yellow-500' : 'text-muted'" title="{{ state('draft') ? 'Brouillon actif' : 'Pas un brouillon' }}" aria-label="{{ state('draft') ? 'Brouillon actif' : 'Pas un brouillon' }}" (click)="toggleState('draft')">
|
||||
@if (state('draft')) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3h18v4H3z" />
|
||||
<path d="M6 7v14h12V7" />
|
||||
<path d="M9 12h6" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('template')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('template') ? 'text-amber-500' : 'text-muted'" title="{{ state('template') ? 'Modèle' : 'Non modèle' }}" aria-label="{{ state('template') ? 'Modèle' : 'Non modèle' }}" (click)="toggleState('template')">
|
||||
@if (state('template')) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="14" rx="2" />
|
||||
<path d="M7 8h10" />
|
||||
<path d="M7 12h6" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="5" width="18" height="12" rx="2" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('task')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('task') ? 'text-indigo-500' : 'text-muted'" title="{{ state('task') ? 'Tâche' : 'Pas une tâche' }}" aria-label="{{ state('task') ? 'Tâche' : 'Pas une tâche' }}" (click)="toggleState('task')">
|
||||
@if (state('task')) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<path d="M8 12l2 2 4-4" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('private')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('private') ? 'text-purple-500' : 'text-muted'" title="{{ state('private') ? 'Privé' : 'Public' }}" aria-label="{{ state('private') ? 'Privé' : 'Public' }}" (click)="toggleState('private')">
|
||||
@if (state('private')) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('archive')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('archive') ? 'text-amber-600' : 'text-muted'" title="{{ state('archive') ? 'Document archivé' : 'Document non archivé' }}" aria-label="{{ state('archive') ? 'Document archivé' : 'Document non archivé' }}" (click)="toggleState('archive')">
|
||||
@if (state('archive')) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="22,12 18,12 18,8"/>
|
||||
<path d="M18,2H6a2,2,0,0,0-2,2V22a2,2,0,0,0,2,2H18a2,2,0,0,0,2-2V8Z"/>
|
||||
<line x1="10" y1="12" x2="14" y2="12"/>
|
||||
</svg>
|
||||
@if (viewerType() !== 'markdown') {
|
||||
<div class="not-prose flex-1 min-h-0 flex">
|
||||
<app-smart-file-viewer
|
||||
class="flex-1 min-h-0"
|
||||
[filePath]="note.filePath"
|
||||
[content]="note.rawContent ?? note.content"
|
||||
[allNotes]="allNotes()"
|
||||
[currentNote]="note"
|
||||
></app-smart-file-viewer>
|
||||
</div>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 8v13a2 2 0 01-2 2H5a2 2 0 01-2-2V8"/>
|
||||
<path d="M3 4h18l-2-2H5l-2 2z"/>
|
||||
<line x1="10" y1="12" x2="14" y2="12"/>
|
||||
</svg>
|
||||
<div class="px-4 py-4 lg:px-8 prose prose-lg dark:prose-invert max-w-none prose-p:leading-[1] prose-li:leading-[1] prose-blockquote:leading-[1]" [innerHTML]="sanitizedHtmlContent()"></div>
|
||||
}
|
||||
|
||||
@if (note.backlinks.length > 0) {
|
||||
<div class="px-4 py-4 lg:px-8 mt-12 pt-6 border-t border-border not-prose">
|
||||
<h2 class="text-2xl font-bold mb-4">Backlinks</h2>
|
||||
<ul>
|
||||
@for (backlinkId of note.backlinks; track backlinkId) {
|
||||
<li class="mb-2">
|
||||
<a
|
||||
(click)="noteLinkClicked.emit(backlinkId)"
|
||||
class="cursor-pointer text-accent hover:underline">
|
||||
{{ formatBacklinkId(backlinkId) }}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div [innerHTML]="sanitizedHtmlContent()"></div>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="note() as backlinksNote">
|
||||
@if (backlinksNote.backlinks.length > 0) {
|
||||
<div class="mt-12 pt-6 border-t border-border not-prose">
|
||||
<h2 class="text-2xl font-bold mb-4">Backlinks</h2>
|
||||
<ul>
|
||||
@for (backlinkId of backlinksNote.backlinks; track backlinkId) {
|
||||
<li class="mb-2">
|
||||
<a
|
||||
(click)="noteLinkClicked.emit(backlinkId)"
|
||||
class="cursor-pointer text-accent hover:underline">
|
||||
{{ formatBacklinkId(backlinkId) }}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
</div>
|
||||
`,
|
||||
@@ -358,6 +390,7 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
|
||||
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
private readonly fileTypeDetector = inject(FileTypeDetectorService);
|
||||
private readonly previewService = inject(NotePreviewService);
|
||||
private readonly clipboard = inject(ClipboardService);
|
||||
private readonly toast = inject(ToastService);
|
||||
@@ -380,6 +413,7 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
readonly menuOpen = signal(false);
|
||||
readonly copyStatus = signal('');
|
||||
|
||||
|
||||
// Edition state
|
||||
readonly isEditMode = this.editorState.isEditMode;
|
||||
|
||||
@@ -387,6 +421,16 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
this.sanitizer.bypassSecurityTrustHtml(this.noteHtmlContent())
|
||||
);
|
||||
|
||||
readonly viewerType = computed(() => {
|
||||
try {
|
||||
const n = this.note();
|
||||
return this.fileTypeDetector.getViewerType(n.filePath, n.rawContent ?? n.content);
|
||||
} catch {
|
||||
return 'markdown' as const;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
frontmatterTags = computed<string[]>(() => {
|
||||
const tags = this.note().frontmatter?.tags;
|
||||
const headerTags = new Set(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
export interface FileTypeInfo {
|
||||
type: 'markdown' | 'excalidraw' | 'image' | 'pdf' | 'unknown';
|
||||
type: 'markdown' | 'excalidraw' | 'image' | 'pdf' | 'video' | 'code' | 'unknown';
|
||||
isEditable: boolean;
|
||||
requiresSpecialViewer: boolean;
|
||||
icon: string;
|
||||
@@ -52,6 +52,29 @@ export class FileTypeDetectorService {
|
||||
return path.toLowerCase().endsWith('.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte si un fichier est une vidéo
|
||||
*/
|
||||
isVideoFile(path: string): boolean {
|
||||
if (!path) return false;
|
||||
const normalized = path.toLowerCase();
|
||||
const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm'];
|
||||
return videoExtensions.some(ext => normalized.endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte si un fichier est un fichier de code/texte structuré
|
||||
*/
|
||||
isCodeFile(path: string): boolean {
|
||||
if (!path) return false;
|
||||
const normalized = path.toLowerCase();
|
||||
const codeExt = [
|
||||
'.json','.py','.java','.ts','.js','.mjs','.cjs','.cs','.cpp','.c','.hpp','.h','.go','.rs','.rb','.php','.kt','.kts','.scala','.swift','.sh','.bash','.ps1','.zsh','.yml','.yaml','.toml','.ini','.conf','.cfg','.env','.xml','.xsd','.xslt','.sql','.dockerfile','.makefile','.gradle','.cmake','.tex','.r','.jl','.lua',
|
||||
'.html','.css','.jsx','.tsx'
|
||||
];
|
||||
return codeExt.some(ext => normalized.endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les informations complètes sur le type de fichier
|
||||
*/
|
||||
@@ -97,6 +120,25 @@ export class FileTypeDetectorService {
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isVideoFile(path)) {
|
||||
return {
|
||||
type: 'video',
|
||||
isEditable: false,
|
||||
requiresSpecialViewer: false,
|
||||
icon: '🎬',
|
||||
mimeType: 'video/*'
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isCodeFile(path)) {
|
||||
return {
|
||||
type: 'code',
|
||||
isEditable: false,
|
||||
requiresSpecialViewer: true,
|
||||
icon: '</>'
|
||||
} as any;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'unknown',
|
||||
isEditable: false,
|
||||
@@ -154,7 +196,7 @@ export class FileTypeDetectorService {
|
||||
/**
|
||||
* Détermine le viewer approprié pour un fichier
|
||||
*/
|
||||
getViewerType(path: string, content?: string): 'markdown' | 'excalidraw' | 'image' | 'pdf' | 'text' | 'unknown' {
|
||||
getViewerType(path: string, content?: string): 'markdown' | 'excalidraw' | 'image' | 'pdf' | 'video' | 'code' | 'text' | 'unknown' {
|
||||
// Check file extension first
|
||||
if (this.isExcalidrawFile(path)) {
|
||||
return 'excalidraw';
|
||||
@@ -180,8 +222,16 @@ export class FileTypeDetectorService {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
if (this.isVideoFile(path)) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (this.isCodeFile(path)) {
|
||||
return 'code';
|
||||
}
|
||||
|
||||
// Check if it's a text file
|
||||
const textExtensions = ['.txt', '.json', '.xml', '.csv', '.log', '.yaml', '.yml', '.toml'];
|
||||
const textExtensions = ['.txt', '.csv', '.log'];
|
||||
if (textExtensions.some(ext => path.toLowerCase().endsWith(ext))) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
@@ -267,7 +267,40 @@ export class VaultService implements OnDestroy {
|
||||
}
|
||||
|
||||
getNoteById(id: string): Note | undefined {
|
||||
return this.notesMap().get(id);
|
||||
const existing = this.notesMap().get(id);
|
||||
if (existing) return existing;
|
||||
// Fallback: synthesize a virtual note from fast metadata (for non-markdown files)
|
||||
const path = this.idToPathIndex.get(id) || this.slugIdToPathIndex.get(id);
|
||||
const meta = path ? this.metaByPathIndex.get(path) : undefined;
|
||||
if (!meta || !path) return undefined;
|
||||
|
||||
const filePath = this.normalizePath(path);
|
||||
const fileName = filePath.split('/').pop() || filePath;
|
||||
const originalPath = filePath.replace(/\.md$/i, '').replace(/\\/g, '/');
|
||||
const title = meta.title || fileName;
|
||||
const createdAt = meta.createdAt || undefined;
|
||||
const updatedAt = meta.updatedAt || undefined;
|
||||
|
||||
const virtualNote: Note = {
|
||||
id,
|
||||
title,
|
||||
content: '',
|
||||
rawContent: '',
|
||||
tags: [],
|
||||
frontmatter: {} as any,
|
||||
backlinks: [],
|
||||
mtime: Date.parse(updatedAt || createdAt || '') || 0,
|
||||
fileName,
|
||||
filePath,
|
||||
originalPath,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
// Cache synthesized note to make it selectable
|
||||
const next = new Map(this.notesMap());
|
||||
next.set(id, virtualNote);
|
||||
this.notesMap.set(next);
|
||||
return virtualNote;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,7 +360,36 @@ export class VaultService implements OnDestroy {
|
||||
if (!id || this.getNoteById(id)) return !!id;
|
||||
|
||||
const path = this.idToPathIndex.get(id) || this.slugIdToPathIndex.get(id);
|
||||
return path ? this.ensureNoteLoadedByPath(path) : false;
|
||||
if (!path) return false;
|
||||
// Try to load real markdown content; if not possible, synthesize virtual note
|
||||
const ok = await this.ensureNoteLoadedByPath(path);
|
||||
if (ok) return true;
|
||||
const meta = this.metaByPathIndex.get(path);
|
||||
if (!meta) return false;
|
||||
// Create a virtual note for non-markdown types
|
||||
const filePath = this.normalizePath(path);
|
||||
const fileName = filePath.split('/').pop() || filePath;
|
||||
const originalPath = filePath.replace(/\.md$/i, '').replace(/\\/g, '/');
|
||||
const title = meta.title || fileName;
|
||||
const createdAt = meta.createdAt || undefined;
|
||||
const updatedAt = meta.updatedAt || undefined;
|
||||
const virtualNote: Note = {
|
||||
id,
|
||||
title,
|
||||
content: '',
|
||||
rawContent: '',
|
||||
tags: [],
|
||||
frontmatter: {} as any,
|
||||
backlinks: [],
|
||||
mtime: Date.parse(updatedAt || createdAt || '') || 0,
|
||||
fileName,
|
||||
filePath,
|
||||
originalPath,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
this.addNoteToMap(virtualNote);
|
||||
return true;
|
||||
}
|
||||
|
||||
async ensureNoteLoadedByPath(path: string): Promise<boolean> {
|
||||
@@ -343,6 +405,7 @@ export class VaultService implements OnDestroy {
|
||||
this.addNoteToMap(note);
|
||||
return true;
|
||||
} catch {
|
||||
// Non-markdown or unsupported types will fail here; signal caller to synthesize
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -395,6 +458,12 @@ export class VaultService implements OnDestroy {
|
||||
return path ? this.metaByPathIndex.get(path) : undefined;
|
||||
}
|
||||
|
||||
getFastMetaByPath(filePath: string): FileMetadata | undefined {
|
||||
if (!filePath) return undefined;
|
||||
const p = this.normalizePath(filePath);
|
||||
return this.metaByPathIndex.get(p);
|
||||
}
|
||||
|
||||
async updateNoteTags(noteId: string, tags: string[]): Promise<boolean> {
|
||||
const note = this.getNoteById(noteId);
|
||||
if (!note?.filePath) return false;
|
||||
@@ -589,12 +658,16 @@ export class VaultService implements OnDestroy {
|
||||
}
|
||||
|
||||
private buildTrashTree(): VaultNode[] {
|
||||
// Touch fast tree to make this computed reactive to file metadata updates
|
||||
const _fast = this.fastFileTree();
|
||||
|
||||
const root = this.createFolder(TRASH_FOLDER, TRASH_FOLDER, true);
|
||||
const folders = new Map<string, VaultFolder>([[TRASH_FOLDER, root]]);
|
||||
const openFolders = this.openFolderPaths();
|
||||
|
||||
for (const note of this.allNotes()) {
|
||||
const filePath = this.normalizePath(note.filePath || note.originalPath || '');
|
||||
// Include ALL files (not only markdown notes) from metadata index
|
||||
for (const [pathKey] of this.metaByPathIndex.entries()) {
|
||||
const filePath = this.normalizePath(pathKey);
|
||||
if (!this.isInTrash(filePath)) continue;
|
||||
|
||||
const segments = this.parseTrashFolderSegments(filePath);
|
||||
@@ -602,7 +675,9 @@ export class VaultService implements OnDestroy {
|
||||
? this.ensureTrashFolderPath(segments, folders, root, openFolders)
|
||||
: root;
|
||||
|
||||
this.addFileNode(parentFolder, note.filePath, note.id, note.fileName);
|
||||
const id = this.buildSlugIdFromPath(filePath);
|
||||
const fileName = filePath.split('/').pop() || filePath;
|
||||
this.addFileNode(parentFolder, filePath, id, fileName);
|
||||
}
|
||||
|
||||
this.sortAndCleanFolderChildren(root);
|
||||
@@ -706,14 +781,18 @@ export class VaultService implements OnDestroy {
|
||||
}
|
||||
|
||||
private calculateFolderCounts(): Record<string, number> {
|
||||
// Touch fast tree to make this computed reactive to file metadata updates
|
||||
const _fast = this.fastFileTree();
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (const note of this.allNotes()) {
|
||||
const path = this.normalizePath(note.originalPath || note.filePath || '');
|
||||
// Count ALL files from metadata index (fast, includes non-markdown)
|
||||
for (const [pathKey] of this.metaByPathIndex.entries()) {
|
||||
const path = this.normalizePath(pathKey);
|
||||
if (!path || this.isInTrash(path) || this.isBuiltinPath(path)) continue;
|
||||
|
||||
const parts = path.split('/');
|
||||
parts.pop(); // Remove filename
|
||||
parts.pop(); // remove filename
|
||||
|
||||
let acc = '';
|
||||
for (const segment of parts) {
|
||||
@@ -727,6 +806,9 @@ export class VaultService implements OnDestroy {
|
||||
}
|
||||
|
||||
private calculateTrashFolderCounts(): Record<string, number> {
|
||||
// Touch fast tree to make this computed reactive to file metadata updates
|
||||
const _fast = this.fastFileTree();
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
const increment = (path: string) => {
|
||||
@@ -735,8 +817,9 @@ export class VaultService implements OnDestroy {
|
||||
counts[norm] = (counts[norm] ?? 0) + 1;
|
||||
};
|
||||
|
||||
for (const note of this.allNotes()) {
|
||||
const filePath = this.normalizePath(note.filePath || note.originalPath || '');
|
||||
// Count ALL files from metadata index that are in trash
|
||||
for (const [pathKey] of this.metaByPathIndex.entries()) {
|
||||
const filePath = this.normalizePath(pathKey);
|
||||
if (!this.isInTrash(filePath)) continue;
|
||||
|
||||
const segments = this.parseTrashFolderSegments(filePath);
|
||||
@@ -750,6 +833,7 @@ export class VaultService implements OnDestroy {
|
||||
|
||||
let current = TRASH_FOLDER;
|
||||
for (const segment of segments) {
|
||||
if (!segment) continue;
|
||||
current = `${current}/${segment}`;
|
||||
increment(current);
|
||||
}
|
||||
|
||||
@@ -636,6 +636,20 @@ html, body {
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Full-bleed container for non-markdown viewers */
|
||||
.ov-fullbleed {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
/* Ensure viewer wrappers are never constrained by inherited max-width */
|
||||
.ov-viewer,
|
||||
.ov-pdf,
|
||||
.ov-excalidraw {
|
||||
max-width: none !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Mobile animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
|
||||
Reference in New Issue
Block a user