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:
Bruno Charest 2025-10-30 12:05:00 -04:00
parent b1f142c4f7
commit 6f01d65411
42 changed files with 2182 additions and 504 deletions

View File

@ -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,

212
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -4,5 +4,11 @@
"secure": false,
"changeOrigin": true,
"logLevel": "warn"
},
"/vault": {
"target": "http://localhost:4000",
"secure": false,
"changeOrigin": true,
"logLevel": "warn"
}
}

View File

@ -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);

View File

@ -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);
}
}
}

View File

@ -1,103 +1,91 @@
<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é'}}"
<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
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 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 *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 class="sr-only">{{ dirty() ? 'Non sauvegardé' : 'Sauvegardé' }}</span>
</span>
</button>
</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>
<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>
</svg>
{{isFullscreen() ? 'Quitter FS' : 'Plein écran'}}
<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 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>
<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 -->
@ -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

View File

@ -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);

View File

@ -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',
@ -80,6 +81,11 @@ import { VaultService } from '../../../services/vault.service';
<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">
<button type="button"
@ -103,8 +109,13 @@ 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">
<!-- 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>
@ -118,6 +129,7 @@ import { VaultService } from '../../../services/vault.service';
</div>
</div>
</div>
</div>
<!-- List Container -->
<div class="flex-1 min-h-0 overflow-y-auto list-scroll" appScrollableOverlay #listContainer>
@ -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) {

View File

@ -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;
}
});
}

View File

@ -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>();
}

View File

@ -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 });
}
}

View File

@ -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>

View File

@ -0,0 +1,6 @@
:host {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}

View File

@ -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';
}
});
}

View File

@ -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 });
}
}

View File

@ -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">

View File

@ -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;
}

View File

@ -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">&lt;/&gt;</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';
}
}

View File

@ -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 {

View File

@ -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 [];
}

View File

@ -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)
*/

View File

@ -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); }
}

View File

@ -1,56 +1,24 @@
@if(note(); as note) {
<div class="mx-auto flex max-w-7xl">
<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&nbsp;: {{ 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>
}
<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>
}
<article class="prose prose-lg max-w-none dark:prose-invert"
[innerHTML]="noteHtmlContent()"></article>
</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>
</div>
</aside>
}
</div>
}

View File

@ -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,14 +76,25 @@ 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">
<div class="note-viewer-scroll">
<!-- 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">
<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"
@ -132,6 +153,7 @@ export interface WikiLinkActivation {
</svg>
</button>
<button
type="button"
class="note-toolbar-icon"
@ -175,9 +197,8 @@ export interface WikiLinkActivation {
[initialContent]="note.rawContent ?? note.content"
/>
} @else {
@if (frontmatterTags().length > 0) {
<div class="mb-6 md-tag-group not-prose">
<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"
@ -192,7 +213,7 @@ export interface WikiLinkActivation {
</div>
}
<div class="not-prose flex flex-col gap-2 text-sm text-text-muted my-4">
<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">
@ -304,7 +325,7 @@ export interface WikiLinkActivation {
</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 8v13a2 2 0 01-2 2H5a2 2 0 01-2-2V8"/>
<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>
@ -314,16 +335,25 @@ export interface WikiLinkActivation {
</div>
</div>
<div [innerHTML]="sanitizedHtmlContent()"></div>
@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 {
<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>
}
</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">
@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 backlinksNote.backlinks; track backlinkId) {
@for (backlinkId of note.backlinks; track backlinkId) {
<li class="mb-2">
<a
(click)="noteLinkClicked.emit(backlinkId)"
@ -335,6 +365,8 @@ export interface WikiLinkActivation {
</ul>
</div>
}
}
</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(

View File

@ -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';
}

View File

@ -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);
}

View File

@ -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 {

View File

@ -13,10 +13,10 @@
"state": {
"type": "excalidraw",
"state": {
"file": "Drawing-20251028-1452.excalidraw.md"
"file": "dessin.excalidraw.md"
},
"icon": "excalidraw-icon",
"title": "Drawing-20251028-1452.excalidraw"
"title": "dessin.excalidraw"
}
}
]
@ -178,11 +178,22 @@
},
"active": "8309e5042a8fb85c",
"lastOpenFiles": [
"Dessin-02.png",
"mixe/Relaxing Music relax music music _hls-480_.mp4",
"Dessin-02.excalidraw.md",
"dessin.excalidraw.md",
"Drawing-20251028-1452.excalidraw.md",
"Dessin-02.excalidraw.md.tmp",
"Dessin-02.excalidraw.md.bak",
"mixe/ThinkBook_16_G7_ARP_Spec.pdf",
"mixe/Claude_ObsiViewer_V1.png",
"mixe/image_no_bg_clean.svg",
"mixe/dessin.json",
"mixe",
"New folder",
"Drawing-20251028-1452.png",
"Drawing-20251028-1452.excalidraw.md.bak",
"Drawing-20251028-1452.excalidraw.md",
"dessin.svg",
"dessin.excalidraw.md",
"dessin.png",
"dessin.excalidraw.md.bak",
"dessin_05.svg",
@ -190,16 +201,9 @@
"dessin_05.excalidraw.md",
"dessin_03.excalidraw.md",
"dessin_05.excalidraw.md.bak",
"dessin_04.excalidraw.md.tmp",
"dessin_04.excalidraw.md.bak",
"dessin_04.excalidraw.md",
"dessin-002.excalidraw.md",
"dessin_03.excalidraw.md.tmp",
"dessin_03.excalidraw.md.bak",
"dessin-002.excalidraw.md.tmp",
"dessin-002.excalidraw.md.bak",
"Dessin_001.excalidraw.md",
"Dessin_001.excalidraw.md.tmp",
"Drawing 2025-10-28 11.11.59.excalidraw.md",
"dessin-06.excalidraw.md",
"Dessin-5.excalidraw.md",
@ -217,7 +221,6 @@
"HOME.md",
"Drawing-20251027-1914.excalidraw.md",
"Drawing-20251027-1705.excalidraw.md",
"Drawing 2025-10-27 16.43.48.excalidraw.md",
"Untitled.canvas"
]
}

View File

@ -0,0 +1,15 @@
---
excalidraw-plugin: parsed
updated: "2025-10-30T01:40:26.435Z"
---
==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==
# Excalidraw Data
## Text Elements
%%
## Drawing
```compressed-json
N4IgLgngDgpiBcIYA8DGBDANgSwCYCd0B3EAGhADcZ8BnbAewDsEAmcm+gV31TkXoBGdXNnSMAtCgw4CxcVEycA5tmbkYmGAFsYjMDQQBtUJFgIQI9Fqa4ylanSYIAjAAYAbOSq0GjAHJMvC4ArAAcrgAsAOyh7s7k2DQAIhowYDC28ABmWDQwCZkgAOoQAF4CuMgA+izoFAAKLABKAPKlAELiABbtyEp2WdiYmADKkJrmHDJ2NGD49ADWMEV4YF2s7HOLMGMQE4hTeHbzyl2MMDQG8PEg9FDoqNiQLq6u5GJK+28gyAgAzBFvhBWK9NvMlgBheiYej4cwAYmcMCRSLsAgeCyUJ0YuChMLhiDmYho93wujAdiIq3W8BiADoIgBOZks1ks8hdGDYJRdCnwRlsEB5DIuCKhAVuYLBRnkLFcKAASVwV0MAF1yFlCDolQhGJxhuRsbhzpcECZoHwWABfcgCLg4gCimh0ehV6pAnCguHQ6UyziicXcjKiUUZQei5BwjAWuv1mEj9FQS0yOUweRt5rMiHQ+HmJC8Dl8CCDBZ8TACjCC11czlCLFcjOiMpAiRSml9CDmnHyLcKCoAGs5MMERgA1egAaQhWWQCzAUFKrnaAFUIMuBkNRuM+ELoUcwdsVrg1hshVslrt9rvpoauDyTVcbncHk9gdXvh8vuRfvA/uFyG+9YsHSUTBAekLQrCCIosicC2hicqcDieJQYShCMCSObkpS1IIK4IFgSAnLcryCCxHShHCn6QZRH8kSuFENyIYqypGO6mpWDAOrwHqBogEaD5muAFqsDaIB2khuBOto5JuuQnreh21wBv6ERSo2oTOM2UYxjxcYJkmIrZLkPazDmYDtKoIiMP0el8bouCWTiqi2bx8YgJg6CzFCWhaE8vr1PQqh8m5mzmQAgrm9BEJy6CZKFSA4pFeaxYUOZ5nYUBBa6RiGG8rjqnlBGkORwSqqqGbCVmSDDNgUB5HY3iOMw1Y3E1vgVlWzh/Iyzh9X8fxaQkySpEpqZ5AU5gAJrIAqEIACpJMg7SYPgpTzagFBVBEFAAFYADJFFAG7DJeO6HLY4HLLh8CCrM4I7Nukx7pd/F3mcFyPuQz6PM877vDZX4/P8ETOHSLCEW+WmeGeD0oQSICIrBqLwUmiHIZBCNEhhpLYeQVLHjSAYURyXI8nyjbFUKMBGWKwR/gKoRRLKJwsXJIAcdq8X6W9kmCfAmaWmJEmOs6slsfJXo+kZ/pxFEanSnWYaRqoukJTChkpiZlWmDuljWDijWFk41xxKWzWdXwjKxAxfzBBDw1tmkRnjT2RyIPN/bdPUSgAI4QhO7gAIrhUQRAQqEABaoQALLtBQJ1bns50vTM57XYTp73dsZ3PTevP3p9Ljffcv1Q6CICfnw3w/nbwTg5DrAsBEDdXfD0HI3B4kIUa7docSuN6DhmfwNEdJshPzKhKTJF8j1JPU0ZUSuM3LC1qEfws/KSrs5zXHc3xAlFwLVVC7a9pSWLOXwGqkuKTLKny0zwTRA2KvRrGfEa8mCCu1a7roCgFAMY0shI0C6NFIoGhUD0B0CMVAZJdC/xMuQNYMlzDenwDGcgqBuBkj0BCLo5l5oiUQOiOEOC8HkgVOkLQ7Qe4Xz7uAdCmF8EUkobmahtCHRJSijFGAcVzDpWinYXBnC9A0O0AAMU3LnA4qcOFsMkVoKRTAwBSKsEMKGiiuHSLUSMbApRLTfDEUo2hLRS6vheCYqhEjaFNHevzG4pjdFaBAfgMAyVoqpU/u5FxdjtBjDhpjDuMFRG2LAMohxfNj680NjogJbj05yOvPuEA/jIm0KCYeG6goMnKPmigTxOAlAtQ8jALI7D0ncA4PgdonAwBgBNh6Y67xUBgGwFQB0WgBAZG9AIfYCVZBKBUDZaSLoQo8wyE8Fy4zsJ2XctMjpNkADirNuIJSWS5faqgBH4DmUPBZbSOlUHmvQaEQldaTFSO0oslDZiwJIVmdWiYf7GTTD2Tysxwq3NOeczAvixKwEYDHeguA+Cu3IMClI6R2kuxQUgKK+AY6fXQEoPgmzkBZQ8fQtGRpOz4G7OoLFsIwDwKwJaYl2KwDdN6bgeBugIUIpQNSlYawkg5gWKC8FyCPkaiGDAAAEmIXAgyeZYjwAYoxvjhpOWskoHh6ABlGS7D2cFOR9RkrwDAchSRXkZHqGSLI1BdBVkhS2Gg+16BxRcryiaFqmgXEMba959rEgOLAD6F15qvlgECsFageqiCMDZTSEA1hOANXIFoTV2ADmTL4owTi5hlx6CeJoXA4ggLBHEG4LNoRc3BGcBEURaiikor1DK24wKK2cCrXcXQgUoCenrcCgx4LyGtt0EkUQMJXI83uLMGAPasD0FsqAcB0UWrmsUugQFUKyQUAYJGkYNzfTxp3kJGac1FrLVWutTa209qHWOvAVVYkyR0FKLMq+Cb3I0AQdCDNLQGnCCZXyoUj7hj9gQOIOi+FQLsC/ZgKav6+qhAXnkTQcLL4yQkaxE+ntvZ+wDsHUO4co6x3jgS7sYkoMwBg2s7eCHgB4bXRkeNNBIowHaFyGySRCAjPhR+/DtymDxqrZO/UuIHicgVKU2EMBI7nK0Ha0yECiAgP0GJsKOKL4caOUKZQ6Kh2OSsi5XeWoYCOpxNQF1oBdBKozThnsqAcCntVeQJNOgTPfQaVGPglnw2cuoH4ZNZ7CU9i0C5/ZSblWZHPRqLTZzBUkRKaRRTWybJSK01W1IEyaAhbC7PKtTSvJ3q8NgGARBcWYl7iExA8IsihCyIyHIdgjEgrBTARV/mZMgFKCJoSFAsDdlEpl7L3Kat+eM66nswL5UKm8+ijdB970ScFRafAOkm0tr6+wcjuAdnnBzAphKNAk1QGWxcCWtx8DclUCMTbLQshZDyBl24AhdoEf0EdwBXXau9fNfQU7539oVL5FEFgm9bivbSGc09LB3B/AIjEZwDsQAExPL+YtM9yYIGCCvMSgxNBXFI1aIAA===
```
%%

BIN
vault/Dessin-02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -1,15 +0,0 @@
---
excalidraw-plugin: parsed
updated: "2025-10-29T00:30:06.609Z"
---
==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==
# Excalidraw Data
## Text Elements
%%
## Drawing
```compressed-json
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
```
%%

View File

@ -1,6 +1,6 @@
---
excalidraw-plugin: parsed
updated: "2025-10-28T18:48:43.751Z"
updated: "2025-10-30T11:48:55.327Z"
---
==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==
@ -10,6 +10,6 @@ updated: "2025-10-28T18:48:43.751Z"
%%
## Drawing
```compressed-json
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
```
%%

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 946 KiB

View File

@ -0,0 +1,72 @@
<#
.SYNOPSIS
Exemple de script PowerShell de démonstration.
.DESCRIPTION
Ce script montre comment :
- Définir des variables
- Lire des entrées utilisateur
- Utiliser des conditions et des boucles
- Écrire dans un fichier de log
- Gérer des erreurs
.AUTHOR
Bruno Charest
#>
# =======================
# ⚙️ Variables de base
# =======================
$UserName = $env:USERNAME
$DateNow = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$LogFile = "C:\Logs\ExampleScript.log"
# Crée le répertoire de logs si nécessaire
if (!(Test-Path (Split-Path $LogFile))) {
New-Item -Path (Split-Path $LogFile) -ItemType Directory -Force | Out-Null
}
# =======================
# 📝 Fonction pour journaliser les événements
# =======================
function Write-Log {
param (
[string]$Message,
[string]$Level = "INFO"
)
$LogEntry = "$((Get-Date).ToString('yyyy-MM-dd HH:mm:ss')) [$Level] $Message"
Add-Content -Path $LogFile -Value $LogEntry
Write-Host $LogEntry
}
Write-Log "Script démarré par $UserName"
# =======================
# 👋 Interaction utilisateur
# =======================
$name = Read-Host "Quel est ton prénom"
Write-Log "Nom entré : $name"
# =======================
# 🔁 Exemple de boucle
# =======================
for ($i = 1; $i -le 5; $i++) {
Write-Host "Itération $i"
Start-Sleep -Milliseconds 500
}
# =======================
# ⚠️ Exemple de gestion d'erreur
# =======================
try {
# Exemple : tentative de suppression dun fichier inexistant
Remove-Item "C:\FichierInexistant.txt" -ErrorAction Stop
} catch {
Write-Log "Erreur détectée : $($_.Exception.Message)" "ERROR"
}
# =======================
# ✅ Fin du script
# =======================
Write-Log "Script terminé avec succès."
Write-Host "`n✅ Exécution terminée avec succès."

Binary file not shown.

217
vault/mixe/dessin.json Normal file
View File

@ -0,0 +1,217 @@
{
"elements": [
{
"type": "ellipse",
"version": 132,
"versionNonce": 283426111,
"isDeleted": false,
"id": "75xXCsrYeEIpx-bVFdD26",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 152,
"y": 143.5,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"width": 245.50000000000003,
"height": 174.49999999999997,
"seed": 1925880401,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1761676663352,
"link": null,
"locked": false
},
{
"type": "line",
"version": 281,
"versionNonce": 1637513215,
"isDeleted": false,
"id": "Ee7Y120RtAdQsiCUEU0r7",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 209.5,
"y": 158.5,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"width": 5.5,
"height": 147.5,
"seed": 1375870513,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1761676682202,
"link": null,
"locked": false,
"startBinding": null,
"endBinding": null,
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": null,
"points": [
[
0,
0
],
[
5.5,
147.5
]
]
},
{
"type": "line",
"version": 200,
"versionNonce": 1266585960,
"isDeleted": false,
"id": "TwSQiyuxGNT_cWR9zLRTk",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"angle": 0,
"x": 213.57179654033308,
"y": 230.71709399425663,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"width": 187,
"height": 3.5,
"seed": 1447624159,
"groupIds": [],
"frameId": null,
"roundness": {
"type": 2
},
"boundElements": [],
"updated": 1761677094707,
"link": null,
"locked": false,
"startBinding": null,
"endBinding": null,
"lastCommittedPoint": null,
"startArrowhead": null,
"endArrowhead": null,
"points": [
[
0,
0
],
[
187,
-3.5
]
]
}
],
"appState": {
"showWelcomeScreen": true,
"theme": "dark",
"currentChartType": "bar",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
"currentItemFontFamily": 1,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemRoundness": "round",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"activeEmbeddable": null,
"draggingElement": null,
"editingElement": null,
"editingGroupId": null,
"editingLinearElement": null,
"activeTool": {
"type": "hand",
"customType": null,
"locked": false,
"lastActiveTool": null
},
"penMode": false,
"penDetected": false,
"errorMessage": null,
"exportBackground": true,
"exportScale": 2,
"exportEmbedScene": false,
"exportWithDarkMode": false,
"fileHandle": null,
"gridSize": null,
"isBindingEnabled": true,
"defaultSidebarDockedPreference": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"lastPointerDownWith": "touch",
"multiElement": null,
"name": "Untitled-2025-10-28-1436",
"contextMenu": null,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"openDialog": null,
"pasteDialog": {
"shown": false,
"data": null
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrolledOutside": false,
"scrollX": 141.7025899014401,
"scrollY": 113.16506876319784,
"selectedElementIds": {},
"selectedGroupIds": {},
"selectedElementsAreBeingDragged": false,
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showStats": false,
"startBoundElement": null,
"suggestedBindings": [],
"frameRendering": {
"enabled": true,
"clip": true,
"name": true,
"outline": true
},
"frameToHighlight": null,
"editingFrame": null,
"elementsToHighlight": null,
"toast": null,
"viewBackgroundColor": "#f8f9fa",
"zenModeEnabled": false,
"zoom": {
"value": 0.7938006350863728
},
"viewModeEnabled": false,
"pendingImageElementId": null,
"showHyperlinkPopup": false,
"selectedLinearElement": null,
"snapLines": [],
"originSnapOffset": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 723,
"offsetTop": 228,
"width": 665,
"height": 546
},
"files": {}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 825 KiB

61
vault/mixe/test.py Normal file
View File

@ -0,0 +1,61 @@
"""Exemple de script Python illustrant l'usage des dataclasses."""
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Iterable
@dataclass
class Task:
"""Représente une tâche planifiée avec une durée estimée."""
name: str
estimated_minutes: int
created_at: datetime = field(default_factory=datetime.now)
completed: bool = False
def remaining_minutes(self) -> int:
"""Calcule le temps restant estimé."""
return 0 if self.completed else self.estimated_minutes
def mark_done(self) -> None:
"""Marque la tâche comme terminée."""
self.completed = True
def summarize(tasks: Iterable[Task]) -> str:
"""Retourne un résumé textuel de la charge de travail."""
task_list = list(tasks)
total = sum(task.estimated_minutes for task in task_list)
remaining = sum(task.remaining_minutes() for task in task_list)
done = total - remaining
return (
f"Tâches: {len(task_list)}\n"
f"Terminé: {done} min\n"
f"Restant: {remaining} min\n"
f"Charge totale: {total} min"
)
def main() -> None:
"""Point d'entrée du script avec quelques exemples."""
tasks = [
Task("Préparer la réunion", estimated_minutes=45),
Task("Répondre aux emails", estimated_minutes=20),
Task("Prototyper l'interface", estimated_minutes=90),
]
# Marquer une tâche comme terminée pour illustrer la logique.
tasks[1].mark_done()
print("Résumé du jour:")
print(summarize(tasks))
# Afficher l'échéance approximative si toutes les tâches sont démarrées maintenant.
deadline = datetime.now() + timedelta(minutes=sum(task.remaining_minutes() for task in tasks))
print(f"Heure de fin estimée: {deadline:%H:%M}")
if __name__ == "__main__":
main()

View File

@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useRef } from 'react';
// import '@excalidraw/excalidraw/dist/excalidraw.min.css';
import { Excalidraw, exportToBlob, exportToSvg } from '@excalidraw/excalidraw';
import type { AppState, ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types';
import type { ExcalidrawWrapperProps, Scene, SceneChangeDetail, ReadyDetail } from './types';