diff --git a/index.tsx b/index.tsx
index fb6519d..648e912 100644
--- a/index.tsx
+++ b/index.tsx
@@ -5,6 +5,7 @@ import { LOCALE_ID, provideZonelessChangeDetection, APP_INITIALIZER } from '@ang
import localeFr from '@angular/common/locales/fr';
import { AppComponent } from './src/app.component';
+import { provideViewers } from './src/app/services/file-viewer-registry.service';
import { initializeRouterLogging } from './src/core/logging/log.router-listener';
import { initializeVisibilityLogging } from './src/core/logging/log.visibility-listener';
import '@excalidraw/excalidraw';
@@ -16,6 +17,7 @@ bootstrapApplication(AppComponent, {
provideZonelessChangeDetection(),
provideHttpClient(),
{ provide: LOCALE_ID, useValue: 'fr' },
+ ...provideViewers(),
{
provide: APP_INITIALIZER,
useFactory: initializeRouterLogging,
diff --git a/package-lock.json b/package-lock.json
index 0cbc835..002f331 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -68,7 +68,9 @@
"markdown-it-task-lists": "^2.1.1",
"meilisearch": "^0.44.1",
"mermaid": "^11.12.0",
+ "ngx-extended-pdf-viewer": "^25.6.0-alpha.3",
"pathe": "^1.1.2",
+ "pdfjs-dist": "^5.4.296",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-to-webcomponent": "^2.0.0",
@@ -4590,6 +4592,191 @@
"win32"
]
},
+ "node_modules/@napi-rs/canvas": {
+ "version": "0.1.81",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.81.tgz",
+ "integrity": "sha512-ReCjd5SYI/UKx/olaQLC4GtN6wUQGjlgHXs1lvUvWGXfBMR3Fxnik3cL+OxKN5ithNdoU0/GlCrdKcQDFh2XKQ==",
+ "license": "MIT",
+ "optional": true,
+ "workspaces": [
+ "e2e/*"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas-android-arm64": "0.1.81",
+ "@napi-rs/canvas-darwin-arm64": "0.1.81",
+ "@napi-rs/canvas-darwin-x64": "0.1.81",
+ "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.81",
+ "@napi-rs/canvas-linux-arm64-gnu": "0.1.81",
+ "@napi-rs/canvas-linux-arm64-musl": "0.1.81",
+ "@napi-rs/canvas-linux-riscv64-gnu": "0.1.81",
+ "@napi-rs/canvas-linux-x64-gnu": "0.1.81",
+ "@napi-rs/canvas-linux-x64-musl": "0.1.81",
+ "@napi-rs/canvas-win32-x64-msvc": "0.1.81"
+ }
+ },
+ "node_modules/@napi-rs/canvas-android-arm64": {
+ "version": "0.1.81",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.81.tgz",
+ "integrity": "sha512-78Lz+AUi+MsWupyZjXwpwQrp1QCwncPvRZrdvrROcZ9Gq9grP7LfQZiGdR8LKyHIq3OR18mDP+JESGT15V1nXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-arm64": {
+ "version": "0.1.81",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.81.tgz",
+ "integrity": "sha512-omejuKgHWKDGoh8rsgsyhm/whwxMaryTQjJTd9zD7hiB9/rzcEEJLHnzXWR5ysy4/tTjHaQotE6k2t8eodTLnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-x64": {
+ "version": "0.1.81",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.81.tgz",
+ "integrity": "sha512-EYfk+co6BElq5DXNH9PBLYDYwc4QsvIVbyrsVHsxVpn4p6Y3/s8MChgC69AGqj3vzZBQ1qx2CRCMtg5cub+XuQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+ "version": "0.1.81",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.81.tgz",
+ "integrity": "sha512-teh6Q74CyAcH31yLNQGR9MtXSFxlZa5CI6vvNUISI14gWIJWrhOwUAOly+KRe1aztWR0FWTVSPxM4p5y+06aow==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+ "version": "0.1.81",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.81.tgz",
+ "integrity": "sha512-AGEopHFYRzJOjxY+2G1RmHPRnuWvO3Qdhq7sIazlSjxb3Z6dZHg7OB/4ZimXaimPjDACm9qWa6t5bn9bhXvkcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+ "version": "0.1.81",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.81.tgz",
+ "integrity": "sha512-Bj3m1cl4GIhsigkdwOxii4g4Ump3/QhNpx85IgAlCCYXpaly6mcsWpuDYEabfIGWOWhDUNBOndaQUPfWK1czOQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+ "version": "0.1.81",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.81.tgz",
+ "integrity": "sha512-yg/5NkHykVdwPlD3XObwCa/EswkOwLHswJcI9rHrac+znHsmCSj5AMX/RTU9Z9F6lZTwL60JM2Esit33XhAMiw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+ "version": "0.1.81",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.81.tgz",
+ "integrity": "sha512-tPfMpSEBuV5dJSKexO/UZxpOqnYTaNbG8aKa1ek8QsWu+4SJ/foWkaxscra/RUv85vepx6WWDjzBNbNJsTnO0w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-musl": {
+ "version": "0.1.81",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.81.tgz",
+ "integrity": "sha512-1L0xnYgzqn8Baef+inPvY4dKqdmw3KCBoe0NEDgezuBZN7MA5xElwifoG8609uNdrMtJ9J6QZarsslLRVqri7g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+ "version": "0.1.81",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.81.tgz",
+ "integrity": "sha512-57ryVbhm/z7RE9/UVcS7mrLPdlayLesy+9U0Uf6epCoeSGrs99tfieCcgZWFbIgmByQ1AZnNtFI2N6huqDLlWQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/@napi-rs/nice": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz",
@@ -13999,6 +14186,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/ngx-extended-pdf-viewer": {
+ "version": "25.6.0-alpha.3",
+ "resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-25.6.0-alpha.3.tgz",
+ "integrity": "sha512-9l1JJ0FQkdOgVpiQvGvWRXJ7XaLydsx/pgNgWO7CA5+Vn/kd0tsiBbMpmGA92Q3HSmjvQvPMRBlSQmoH6e4PAA==",
+ "license": "Apache-2.0 with Commons-Clause",
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "peerDependencies": {
+ "@angular/common": ">=17.0.0 <21.0.0",
+ "@angular/core": ">=17.0.0 <21.0.0"
+ }
+ },
"node_modules/node-addon-api": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
@@ -14822,6 +15022,18 @@
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"license": "MIT"
},
+ "node_modules/pdfjs-dist": {
+ "version": "5.4.296",
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
+ "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=20.16.0 || >=22.3.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas": "^0.1.80"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
diff --git a/package.json b/package.json
index 8eae4b3..c020ca6 100644
--- a/package.json
+++ b/package.json
@@ -86,7 +86,9 @@
"markdown-it-task-lists": "^2.1.1",
"meilisearch": "^0.44.1",
"mermaid": "^11.12.0",
+ "ngx-extended-pdf-viewer": "^25.6.0-alpha.3",
"pathe": "^1.1.2",
+ "pdfjs-dist": "^5.4.296",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-to-webcomponent": "^2.0.0",
diff --git a/proxy.conf.json b/proxy.conf.json
index 4774dd8..312ccb1 100644
--- a/proxy.conf.json
+++ b/proxy.conf.json
@@ -4,5 +4,11 @@
"secure": false,
"changeOrigin": true,
"logLevel": "warn"
+ },
+ "/vault": {
+ "target": "http://localhost:4000",
+ "secure": false,
+ "changeOrigin": true,
+ "logLevel": "warn"
}
}
diff --git a/server/index.mjs b/server/index.mjs
index bb25442..7672772 100644
--- a/server/index.mjs
+++ b/server/index.mjs
@@ -344,6 +344,61 @@ const buildFileMetadata = (notes) =>
updatedAt: note.updatedAt,
}));
+// Scan vault for NON-markdown files to include in metadata (images/pdf/video/code/others)
+const scanVaultNonNotes = (vaultPath) => {
+ const items = [];
+ const includeExt = new Set([
+ // images
+ '.png','.jpg','.jpeg','.gif','.svg','.webp','.bmp','.ico',
+ // pdf
+ '.pdf',
+ // video
+ '.mp4','.mov','.avi','.mkv','.webm',
+ // code/text configs
+ '.json','.js','.ts','.jsx','.tsx','.html','.css','.yml','.yaml','.toml','.ini','.cfg','.conf','.sh','.bash','.ps1','.bat','.csv','.txt'
+ ]);
+
+ const walk = (dir) => {
+ let entries = [];
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ const entryPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ walk(entryPath);
+ continue;
+ }
+ if (!entry.isFile()) continue;
+
+ const lower = entry.name.toLowerCase();
+ if (lower.endsWith('.md') || lower.endsWith('.excalidraw.md')) continue; // handled elsewhere
+
+ const ext = path.extname(lower);
+ if (!includeExt.has(ext)) continue;
+
+ try {
+ const stats = fs.statSync(entryPath);
+ const relPath = path.relative(vaultPath, entryPath).replace(/\\/g, '/');
+ const id = slugifyPath(relPath.replace(/\.[^.]+$/i, ''));
+ const title = path.basename(relPath);
+ items.push({
+ id,
+ title,
+ path: relPath,
+ createdAt: new Date(stats.birthtimeMs ? stats.birthtimeMs : stats.ctimeMs).toISOString(),
+ updatedAt: new Date(stats.mtimeMs).toISOString(),
+ });
+ } catch {}
+ }
+ };
+
+ walk(vaultPath);
+ return items;
+};
+
const normalizeDateInput = (value) => {
if (!value) {
return null;
@@ -656,6 +711,7 @@ app.get('/api/files/metadata', async (req, res) => {
// Merge .excalidraw files discovered via FS
const drawings = scanVaultDrawings(vaultDir);
+ const nonNotes = scanVaultNonNotes(vaultDir);
const byPath = new Map(items.map(it => [String(it.path).toLowerCase(), it]));
for (const d of drawings) {
const key = String(d.path).toLowerCase();
@@ -663,6 +719,10 @@ app.get('/api/files/metadata', async (req, res) => {
byPath.set(key, d);
}
}
+ for (const f of nonNotes) {
+ const key = String(f.path).toLowerCase();
+ if (!byPath.has(key)) byPath.set(key, f);
+ }
return res.json(Array.from(byPath.values()));
}
@@ -671,11 +731,16 @@ app.get('/api/files/metadata', async (req, res) => {
const notes = await loadVaultNotes(vaultDir);
const base = buildFileMetadata(notes);
const drawings = scanVaultDrawings(vaultDir);
+ const nonNotes = scanVaultNonNotes(vaultDir);
const byPath = new Map(base.map(it => [String(it.path).toLowerCase(), it]));
for (const d of drawings) {
const key = String(d.path).toLowerCase();
if (!byPath.has(key)) byPath.set(key, d);
}
+ for (const f of nonNotes) {
+ const key = String(f.path).toLowerCase();
+ if (!byPath.has(key)) byPath.set(key, f);
+ }
return res.json(Array.from(byPath.values()));
} catch (error) {
console.error('Failed to load file metadata:', error);
diff --git a/src/app/features/code-viewer/code-viewer.component.ts b/src/app/features/code-viewer/code-viewer.component.ts
new file mode 100644
index 0000000..65fe24a
--- /dev/null
+++ b/src/app/features/code-viewer/code-viewer.component.ts
@@ -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: `
+
+
+
+
+
+
⬇️ Download
+
{{ fileName() }}
+
+
+
+
+ {{ i + 1 }}{{ line }}
+
+
+
+
+ `
+})
+export class CodeViewerComponent {
+ @Input() path: string = '';
+ @Input() content: string = '';
+ @Input() editable: boolean = false; // reserved for future
+
+ wrap = signal(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);
+ }
+ }
+}
diff --git a/src/app/features/drawings/drawings-editor.component.html b/src/app/features/drawings/drawings-editor.component.html
index a63b574..2b7fb29 100644
--- a/src/app/features/drawings/drawings-editor.component.html
+++ b/src/app/features/drawings/drawings-editor.component.html
@@ -1,104 +1,92 @@
-
-
-
-
-
-
+
+
+
+
+
+
+ {{ (path || '').split('/').pop() }}
+
+
+ Aucun fichier
+
+
+
+
+ {{ dirty() ? 'Non sauvegardé' : 'Sauvegardé' }}
+
+
+
+
-
@@ -120,9 +108,6 @@
-
-
-
@@ -134,7 +119,7 @@
('info');
hasConflict = signal(false);
isFullscreen = signal(false);
+ // Header menus & helpers (ported from Test Excalidraw)
+ openPicker = signal(false);
+ openExport = signal(false);
+ excalidrawFiles = signal>([]);
+ 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 {
+ 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 {
+ if (this.saveTimer) clearTimeout(this.saveTimer);
+ this.saveTimer = setTimeout(() => this.saveNow(), 800);
+ }
+
private waitForNextSceneChange(host: any, timeoutMs = 300): Promise {
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);
diff --git a/src/app/features/list/notes-list.component.ts b/src/app/features/list/notes-list.component.ts
index d7c4dd1..f39924e 100644
--- a/src/app/features/list/notes-list.component.ts
+++ b/src/app/features/list/notes-list.component.ts
@@ -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">
+
+
+
+ {{ kindIcon(kindFilter()!) }}
+
@@ -103,17 +109,23 @@ import { VaultService } from '../../../services/vault.service';
-
-
-
-
-
- {{ stats.duration }}ms
-
-
-
- {{ stats.duration }}ms
-
+
+
+
+
+ {{ filtered().length }}
+
+
+
+
+
+ {{ stats.duration }}ms
+
+
+
+ {{ stats.duration }}ms
+
+
@@ -166,12 +178,14 @@ import { VaultService } from '../../../services/vault.service';
+
{{ typeIcon(n) }}
{{ n.title }}
+
{{ typeIcon(n) }}
{{ n.title }}
{{ n.filePath }}
@@ -181,6 +195,7 @@ import { VaultService } from '../../../services/vault.service';
+
{{ typeIcon(n) }}
{{ n.title }}
{{ n.filePath }}
@@ -466,6 +481,7 @@ export class NotesListComponent {
tagFilter = input
(null);
quickLinkFilter = input<'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null>(null);
selectedId = input(null);
+ kindFilter = input<'image' | 'video' | 'pdf' | 'markdown' | 'excalidraw' | 'code' | 'all' | null>(null);
@Output() openNote = new EventEmitter();
@Output() queryChange = new EventEmitter();
@@ -482,6 +498,7 @@ export class NotesListComponent {
private pendingSelectId = signal(null);
private editorState = inject(EditorStateService);
private vault = inject(VaultService);
+ private fileTypes = inject(FileTypeDetectorService);
// Delete warning modal state
deleteWarningOpen = signal(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) {
diff --git a/src/app/features/note-view/components/code-renderer/code-renderer.component.ts b/src/app/features/note-view/components/code-renderer/code-renderer.component.ts
new file mode 100644
index 0000000..646291d
--- /dev/null
+++ b/src/app/features/note-view/components/code-renderer/code-renderer.component.ts
@@ -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: `
+
+ `
+})
+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;
+ }
+ });
+}
diff --git a/src/app/features/note-view/components/file-info-panel/file-info-panel.component.ts b/src/app/features/note-view/components/file-info-panel/file-info-panel.component.ts
new file mode 100644
index 0000000..c9c3c13
--- /dev/null
+++ b/src/app/features/note-view/components/file-info-panel/file-info-panel.component.ts
@@ -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: `
+
+
+
+
Type
{{ data?.type || '—' }}
+
Taille
{{ data!.size | number }} o ({{ data!.sizeHuman }})
+
+
Créé
{{ data!.createdAt | date:'medium' }}
+
Modifié
{{ data!.modifiedAt | date:'medium' }}
+
Dimensions
{{ data!.dimensions!.width }} × {{ data!.dimensions!.height }} px
+
Durée
{{ data!.duration }}
+
Frame rate
{{ data!.frameRate }} fps
+
+
+
+ `
+})
+export class FileInfoPanelComponent {
+ @Input() visible: boolean = false;
+ @Input() data: FileMetadataView | null = null;
+ @Output() close = new EventEmitter();
+}
diff --git a/src/app/features/note-view/components/image-viewer/image-viewer.component.ts b/src/app/features/note-view/components/image-viewer/image-viewer.component.ts
new file mode 100644
index 0000000..51b44a1
--- /dev/null
+++ b/src/app/features/note-view/components/image-viewer/image-viewer.component.ts
@@ -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: `
+
+
![]()
+
+ `,
+ 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 });
+ }
+}
diff --git a/src/app/features/note-view/components/pdf-viewer/pdf-viewer.component.html b/src/app/features/note-view/components/pdf-viewer/pdf-viewer.component.html
new file mode 100644
index 0000000..8fadc3d
--- /dev/null
+++ b/src/app/features/note-view/components/pdf-viewer/pdf-viewer.component.html
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/src/app/features/note-view/components/pdf-viewer/pdf-viewer.component.scss b/src/app/features/note-view/components/pdf-viewer/pdf-viewer.component.scss
new file mode 100644
index 0000000..5f8fb79
--- /dev/null
+++ b/src/app/features/note-view/components/pdf-viewer/pdf-viewer.component.scss
@@ -0,0 +1,6 @@
+:host {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+}
\ No newline at end of file
diff --git a/src/app/features/note-view/components/pdf-viewer/pdf-viewer.component.ts b/src/app/features/note-view/components/pdf-viewer/pdf-viewer.component.ts
new file mode 100644
index 0000000..6319f4a
--- /dev/null
+++ b/src/app/features/note-view/components/pdf-viewer/pdf-viewer.component.ts
@@ -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: `
+
+
+
+
+ `,
+ 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(() => 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';
+ }
+ });
+
+}
diff --git a/src/app/features/note-view/components/video-player/video-player.component.ts b/src/app/features/note-view/components/video-player/video-player.component.ts
new file mode 100644
index 0000000..2f42941
--- /dev/null
+++ b/src/app/features/note-view/components/video-player/video-player.component.ts
@@ -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: `
+
+
+
+ `
+})
+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 });
+ }
+}
diff --git a/src/app/features/note/components/properties-popover/properties-popover.component.html b/src/app/features/note/components/properties-popover/properties-popover.component.html
index 5577e60..6367771 100644
--- a/src/app/features/note/components/properties-popover/properties-popover.component.html
+++ b/src/app/features/note/components/properties-popover/properties-popover.component.html
@@ -10,6 +10,24 @@
+
+
+ Infos fichier
+
+ - Type
+ - {{ fi.type }}
+ - Taille
+ - {{ fi.size || 0 | number }} o ({{ fi.sizeHuman }})
+ - Chemin
+ - {{ fi.path }}
+ - Créé le
+ - {{ formatDate(fi.createdAt) }}
+ - Modifié le
+ - {{ formatDate(fi.modifiedAt) }}
+
+
+
+
diff --git a/src/app/features/note/components/properties-popover/properties-popover.component.ts b/src/app/features/note/components/properties-popover/properties-popover.component.ts
index 25f06ca..6d9d010 100644
--- a/src/app/features/note/components/properties-popover/properties-popover.component.ts
+++ b/src/app/features/note/components/properties-popover/properties-popover.component.ts
@@ -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();
@Output() cancelClose = new EventEmitter();
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 {
+ 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;
}
diff --git a/src/app/features/sidebar/nimbus-sidebar.component.ts b/src/app/features/sidebar/nimbus-sidebar.component.ts
index 4c58727..5771859 100644
--- a/src/app/features/sidebar/nimbus-sidebar.component.ts
+++ b/src/app/features/sidebar/nimbus-sidebar.component.ts
@@ -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';
📁
Folders
+
+ ✨
+
-
-
+
+
+
+ 🖼️
+ 🎬
+ 📄
+ 📝
+ ✏️
+ </>
+ ✨ Tout
+
();
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';
+ }
}
diff --git a/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts b/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts
index b04c0b4..a772b15 100644
--- a/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts
+++ b/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts
@@ -35,7 +35,7 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
-
+
-
+
@@ -253,9 +254,9 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
-
+
@@ -286,29 +287,29 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
@if (mobileNav.activeTab() === 'list') {
}
@if (mobileNav.activeTab() === 'page') {
@if (activeView === 'parameters') {
-
+
} @else if (activeView === 'markdown-playground') {
-
+
} @else if (activeView === 'tests-panel') {
-
+
} @else if (activeView === 'tests-excalidraw') {
-
+
} @else {
-
+
@if (selectedNote) {
} @else {
diff --git a/src/app/services/file-viewer-registry.service.ts b/src/app/services/file-viewer-registry.service.ts
new file mode 100644
index 0000000..2e56e7c
--- /dev/null
+++ b/src/app/services/file-viewer-registry.service.ts
@@ -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
;
+}
+
+@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 {
+ 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 [];
+}
diff --git a/src/app/services/url-state.service.ts b/src/app/services/url-state.service.ts
index cf59bca..6210654 100644
--- a/src/app/services/url-state.service.ts
+++ b/src/app/services/url-state.service.ts
@@ -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 = {
+ 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 {
+ 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 {
+ 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)
*/
diff --git a/src/components/smart-file-viewer/smart-file-viewer.component.ts b/src/components/smart-file-viewer/smart-file-viewer.component.ts
index 5755aea..4cc6651 100644
--- a/src/components/smart-file-viewer/smart-file-viewer.component.ts
+++ b/src/components/smart-file-viewer/smart-file-viewer.component.ts
@@ -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: `
@@ -31,41 +36,53 @@ import { EditorStateService } from '../../services/editor-state.service';
+ (editModeRequested)="onEditModeRequested($event)"
+ class="animate-fadeIn">
+
+
+
-
-
![]()
+
-
-
+
+
+
+
+
+
+
-
-
{{ content }}
+
+
{{ resolvedContent() }}
-
+
@@ -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;
+ private codeComponentRef?: ComponentRef;
@Input() filePath: string = '';
@Input() content: string = '';
+ private filePathSig = signal('');
+ private contentSig = signal('');
@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('');
+ 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 {
@@ -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); }
}
diff --git a/src/components/tags-view/note-viewer/note-viewer.component.html b/src/components/tags-view/note-viewer/note-viewer.component.html
index c74596b..46b3770 100644
--- a/src/components/tags-view/note-viewer/note-viewer.component.html
+++ b/src/components/tags-view/note-viewer/note-viewer.component.html
@@ -1,56 +1,24 @@
@if(note(); as note) {
-
-
-
- {{ note.title }}
-
- Mise à jour : {{ note.updatedAt | date:'medium' }}
-
-
- @for(tag of note.tags; track tag) {
- {{ tag }}
- }
-
-
+
+ @if(note.type === 'pdf') {
+
+
+ } @else {
+
+
+
-
-
-
- @if (note.backlinks.length > 0) {
-
- }
-
-
- @if (tableOfContents().length > 0) {
-
+
}
}
diff --git a/src/components/tags-view/note-viewer/note-viewer.component.ts b/src/components/tags-view/note-viewer/note-viewer.component.ts
index 99ed134..9a664d1 100644
--- a/src/components/tags-view/note-viewer/note-viewer.component.ts
+++ b/src/components/tags-view/note-viewer/note-viewer.component.ts
@@ -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: `
-