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:
parent
b1f142c4f7
commit
6f01d65411
@ -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
212
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -4,5 +4,11 @@
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "warn"
|
||||
},
|
||||
"/vault": {
|
||||
"target": "http://localhost:4000",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "warn"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
81
src/app/features/code-viewer/code-viewer.component.ts
Normal file
81
src/app/features/code-viewer/code-viewer.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -8,8 +8,10 @@ import {
|
||||
ElementRef,
|
||||
HostListener,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
SimpleChanges,
|
||||
ViewChild,
|
||||
computed,
|
||||
inject,
|
||||
@ -21,6 +23,7 @@ import { DrawingsFileService, ExcalidrawScene } from './drawings-file.service';
|
||||
import { ExcalidrawIoService } from './excalidraw-io.service';
|
||||
import { DrawingsPreviewService } from './drawings-preview.service';
|
||||
import { ThemeService } from '../../core/services/theme.service';
|
||||
import { VaultService } from '../../../services/vault.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-drawings-editor',
|
||||
@ -30,7 +33,7 @@ import { ThemeService } from '../../core/services/theme.service';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
})
|
||||
export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy, OnChanges {
|
||||
@Input() path: string = '';
|
||||
@Input() showInlineActions: boolean = true;
|
||||
|
||||
@ -43,6 +46,7 @@ export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy
|
||||
private readonly excalIo = inject(ExcalidrawIoService);
|
||||
private readonly previews = inject(DrawingsPreviewService);
|
||||
private readonly theme = inject(ThemeService);
|
||||
private readonly vault = inject(VaultService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
// Propriétés réactives accessibles depuis le template
|
||||
@ -56,6 +60,11 @@ export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy
|
||||
toastType = signal<'success' | 'error' | 'info'>('info');
|
||||
hasConflict = signal<boolean>(false);
|
||||
isFullscreen = signal<boolean>(false);
|
||||
// Header menus & helpers (ported from Test Excalidraw)
|
||||
openPicker = signal(false);
|
||||
openExport = signal(false);
|
||||
excalidrawFiles = signal<Array<{ filePath: string }>>([]);
|
||||
private saveTimer: any = null;
|
||||
|
||||
private saveSub: Subscription | null = null;
|
||||
private dirtyCheckSub: Subscription | null = null;
|
||||
@ -79,6 +88,77 @@ export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy
|
||||
}
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['path'] && !changes['path'].firstChange) {
|
||||
// Reset state and reload when the selected file changes
|
||||
this.error.set(null);
|
||||
this.hasConflict.set(false);
|
||||
this.isLoading.set(true);
|
||||
this.dirty.set(false);
|
||||
this.reloadFromDisk();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Test-page parity helpers =====
|
||||
refreshFileList(): void {
|
||||
try {
|
||||
const notes = this.vault.allNotes();
|
||||
const list = notes
|
||||
.filter(n => /\.excalidraw\.md$/i.test(n.filePath || ''))
|
||||
.map(n => ({ filePath: n.filePath }));
|
||||
this.excalidrawFiles.set(list);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
toggleOpenPicker(): void {
|
||||
this.openPicker.update(v => !v);
|
||||
if (this.openPicker()) this.refreshFileList();
|
||||
}
|
||||
|
||||
toggleExport(): void {
|
||||
this.openExport.update(v => !v);
|
||||
}
|
||||
|
||||
async createNew(): Promise<void> {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
const hh = String(now.getHours()).padStart(2, '0');
|
||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||
const base = `Drawing-${y}${m}${d}-${hh}${mm}`;
|
||||
const name = prompt('Nom du fichier', `${base}.excalidraw.md`);
|
||||
if (!name) return;
|
||||
const file = name.endsWith('.excalidraw.md') ? name : `${name}.excalidraw.md`;
|
||||
const path = file.replace(/^\/+/, '');
|
||||
const scene: ExcalidrawScene = { elements: [], appState: {}, files: {} };
|
||||
const fm = `---\nexcalidraw-plugin: parsed\ncreated: "${now.toISOString()}"\nupdated: "${now.toISOString()}"\ntitle: "${(file || '').replace(/\.excalidraw\.md$/i,'')}"\n---`;
|
||||
const md = this.excalIo.toObsidianMd(scene as any, fm);
|
||||
await firstValueFrom(this.files.putText(path, md));
|
||||
this.path = path;
|
||||
this.dirty.set(false);
|
||||
this.openPicker.set(false);
|
||||
this.reloadFromDisk();
|
||||
}
|
||||
|
||||
openFile(path: string): void {
|
||||
this.path = path;
|
||||
this.openPicker.set(false);
|
||||
this.dirty.set(false);
|
||||
this.reloadFromDisk();
|
||||
}
|
||||
|
||||
closeFile(): void {
|
||||
this.path = '';
|
||||
this.scene.set({ elements: [], appState: { viewBackgroundColor: '#1e1e1e', theme: this.themeName() }, files: {} } as any);
|
||||
this.dirty.set(false);
|
||||
}
|
||||
|
||||
async saveDebounced(): Promise<void> {
|
||||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||
this.saveTimer = setTimeout(() => this.saveNow(), 800);
|
||||
}
|
||||
|
||||
private waitForNextSceneChange(host: any, timeoutMs = 300): Promise<ExcalidrawScene | null> {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
@ -719,6 +799,7 @@ export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.saveSub?.unsubscribe();
|
||||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||
// Clean up fullscreen listener
|
||||
document.removeEventListener('fullscreenchange', () => {
|
||||
this.isFullscreen.set(!!document.fullscreenElement);
|
||||
|
||||
@ -12,6 +12,7 @@ import { NoteContextMenuService } from '../../services/note-context-menu.service
|
||||
import { UrlStateService } from '../../services/url-state.service';
|
||||
import { EditorStateService } from '../../../services/editor-state.service';
|
||||
import { VaultService } from '../../../services/vault.service';
|
||||
import { FileTypeDetectorService } from '../../../services/file-type-detector.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notes-list',
|
||||
@ -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) {
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
import { Component, Input, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import javascript from 'highlight.js/lib/languages/javascript';
|
||||
import typescript from 'highlight.js/lib/languages/typescript';
|
||||
import json from 'highlight.js/lib/languages/json';
|
||||
import python from 'highlight.js/lib/languages/python';
|
||||
import java from 'highlight.js/lib/languages/java';
|
||||
import xml from 'highlight.js/lib/languages/xml';
|
||||
import css from 'highlight.js/lib/languages/css';
|
||||
import bash from 'highlight.js/lib/languages/bash';
|
||||
import yaml from 'highlight.js/lib/languages/yaml';
|
||||
import sql from 'highlight.js/lib/languages/sql';
|
||||
|
||||
hljs.registerLanguage('javascript', javascript);
|
||||
hljs.registerLanguage('typescript', typescript);
|
||||
hljs.registerLanguage('json', json);
|
||||
hljs.registerLanguage('python', python);
|
||||
hljs.registerLanguage('java', java);
|
||||
hljs.registerLanguage('xml', xml);
|
||||
hljs.registerLanguage('html', xml);
|
||||
hljs.registerLanguage('css', css);
|
||||
hljs.registerLanguage('bash', bash);
|
||||
hljs.registerLanguage('yaml', yaml);
|
||||
hljs.registerLanguage('yml', yaml);
|
||||
hljs.registerLanguage('sql', sql);
|
||||
|
||||
@Component({
|
||||
selector: 'app-code-renderer',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
:host { display:block; }
|
||||
.root { border: 1px solid var(--border); background: var(--card); border-radius: 1rem; overflow: hidden; }
|
||||
.header { display:flex; align-items:center; gap:.5rem; padding:.5rem .75rem; font-size:.75rem; color: var(--text-muted); border-bottom: 1px solid var(--border); background: color-mix(in oklab, var(--card) 92%, black 8%); }
|
||||
.title { display:inline-flex; align-items:center; gap:.5rem; font-weight:600; color: var(--text-main); }
|
||||
.title svg { width: 16px; height: 16px; color: var(--accent, #9b87f5); }
|
||||
.lang { margin-left:auto; padding:.125rem .5rem; border:1px solid var(--border); border-radius:.5rem; font-weight:600; color: var(--text-main); }
|
||||
pre { margin:0; padding: .75rem 1rem; font-family: var(--font-mono, ui-monospace, Menlo, Monaco, Consolas, 'Courier New', monospace); font-size:.875rem; line-height:1.5; overflow:auto; }
|
||||
code { display:block; }
|
||||
.hljs { color: var(--text-main); }
|
||||
`],
|
||||
template: `
|
||||
<div class="root animate-fadeIn">
|
||||
<div class="header">
|
||||
<span class="title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M16 18l6-6-6-6"/>
|
||||
<path d="M8 6L2 12l6 6"/>
|
||||
</svg>
|
||||
<span>code</span>
|
||||
</span>
|
||||
<span class="lang">{{ languageLabel() }}</span>
|
||||
</div>
|
||||
<pre><code class="hljs" [innerHTML]="highlighted()"></code></pre>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class CodeRendererComponent {
|
||||
@Input() path: string = '';
|
||||
@Input() content: string = '';
|
||||
|
||||
fileName = computed(() => {
|
||||
const p = this.path || '';
|
||||
return p.split('/').pop() || p.split('\\').pop() || 'code';
|
||||
});
|
||||
|
||||
languageLabel = computed(() => {
|
||||
const src = this.content || '';
|
||||
if (!src.trim()) return 'TEXT';
|
||||
try {
|
||||
const res = hljs.highlightAuto(src);
|
||||
return (res.language || 'text').toUpperCase();
|
||||
} catch {
|
||||
return 'TEXT';
|
||||
}
|
||||
});
|
||||
|
||||
highlighted = computed(() => {
|
||||
const src = this.content || '';
|
||||
try {
|
||||
if (!src.trim()) { return hljs.highlightAuto('').value; }
|
||||
const res = hljs.highlightAuto(src);
|
||||
return res.value;
|
||||
} catch {
|
||||
return hljs.highlightAuto(src).value;
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import { Component, EventEmitter, Input, Output, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
export interface FileMetadataView {
|
||||
type: string;
|
||||
size?: number;
|
||||
sizeHuman?: string;
|
||||
path: string;
|
||||
createdAt?: string;
|
||||
modifiedAt?: string;
|
||||
dimensions?: { width: number; height: number };
|
||||
duration?: string;
|
||||
codec?: string;
|
||||
frameRate?: number;
|
||||
dataRate?: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-file-info-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
:host { display:block; }
|
||||
.panel { position: absolute; top: 0; right: 0; height: 100%; width: min(88vw, 320px); background: var(--card); border-left: 1px solid var(--border); box-shadow: 0 8px 30px rgba(0,0,0,.18); transform: translateX(100%); transition: transform .25s ease, opacity .25s ease; opacity: .98; border-top-left-radius: 12px; border-bottom-left-radius: 12px; }
|
||||
.panel.open { transform: translateX(0); }
|
||||
.header { height: 44px; display:flex; align-items:center; gap:.5rem; padding: 0 .75rem; border-bottom:1px solid var(--border); }
|
||||
.content { padding: .75rem .75rem 1rem; font-size: .875rem; }
|
||||
.row { display:flex; align-items:flex-start; gap:.5rem; padding:.4rem 0; border-bottom:1px dashed var(--border); }
|
||||
.row:last-child { border-bottom: 0; }
|
||||
.k { color: var(--text-muted); width: 120px; flex: 0 0 auto; }
|
||||
.v { color: var(--text-main); word-break: break-word; }
|
||||
.close { margin-left:auto; border-radius:8px; width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center; }
|
||||
.close:hover { background: color-mix(in oklab, var(--card) 90%, black 10%); }
|
||||
`],
|
||||
template: `
|
||||
<div class="panel" [class.open]="visible">
|
||||
<div class="header">
|
||||
<strong>Détails</strong>
|
||||
<button type="button" class="close" (click)="close.emit()" title="Fermer">✕</button>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="row"><div class="k">Type</div><div class="v">{{ data?.type || '—' }}</div></div>
|
||||
<div class="row" *ngIf="data?.size != null"><div class="k">Taille</div><div class="v">{{ data!.size | number }} o <span class="text-muted">({{ data!.sizeHuman }})</span></div></div>
|
||||
<div class="row"><div class="k">Chemin</div><div class="v">{{ data?.path }}</div></div>
|
||||
<div class="row" *ngIf="data?.createdAt"><div class="k">Créé</div><div class="v">{{ data!.createdAt | date:'medium' }}</div></div>
|
||||
<div class="row" *ngIf="data?.modifiedAt"><div class="k">Modifié</div><div class="v">{{ data!.modifiedAt | date:'medium' }}</div></div>
|
||||
<div class="row" *ngIf="data?.dimensions"><div class="k">Dimensions</div><div class="v">{{ data!.dimensions!.width }} × {{ data!.dimensions!.height }} px</div></div>
|
||||
<div class="row" *ngIf="data?.duration"><div class="k">Durée</div><div class="v">{{ data!.duration }}</div></div>
|
||||
<div class="row" *ngIf="data?.frameRate"><div class="k">Frame rate</div><div class="v">{{ data!.frameRate }} fps</div></div>
|
||||
<div class="row" *ngIf="data?.codec"><div class="k">Codec</div><div class="v">{{ data!.codec }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class FileInfoPanelComponent {
|
||||
@Input() visible: boolean = false;
|
||||
@Input() data: FileMetadataView | null = null;
|
||||
@Output() close = new EventEmitter<void>();
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-image-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="img-root">
|
||||
<img [src]="src" [alt]="alt" (load)="onLoad($event)" class="media" loading="lazy" />
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host { display:block; height:100%; }
|
||||
.img-root { height:100%; display:flex; align-items:center; justify-content:center; padding: 1rem; }
|
||||
.media { max-width: 100%; max-height: 90vh; object-fit: contain; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.16); }
|
||||
`]
|
||||
})
|
||||
export class ImageViewerComponent {
|
||||
@Input() src: string = '';
|
||||
@Input() alt: string = '';
|
||||
@Output() dimensions = new EventEmitter<{ width: number; height: number }>();
|
||||
|
||||
onLoad(ev: Event) {
|
||||
const el = ev.target as HTMLImageElement | null;
|
||||
if (!el) return;
|
||||
this.dimensions.emit({ width: el.naturalWidth || el.width || 0, height: el.naturalHeight || el.height || 0 });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
<div class="w-full h-full overflow-auto">
|
||||
<ngx-extended-pdf-viewer
|
||||
[src]="cleanSrc()"
|
||||
[zoom]="'page-width'"
|
||||
height="100%">
|
||||
</ngx-extended-pdf-viewer>
|
||||
|
||||
</div>
|
||||
@ -0,0 +1,6 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
import { Component, Input, computed, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||
|
||||
@Component({
|
||||
selector: 'app-pdf-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="pdf-container">
|
||||
<div class="pdf-header">
|
||||
<span class="pdf-icon">📄</span>
|
||||
<span class="pdf-title">PDF</span>
|
||||
<span class="pdf-filename">{{ fileName() }}</span>
|
||||
</div>
|
||||
<iframe class="viewer" [src]="safeSrc()" title="PDF Viewer"></iframe>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host { display:flex; flex:1 1 auto; width:100%; min-width:0; min-height:0; padding: 0; }
|
||||
.pdf-container {
|
||||
flex:1 1 auto;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
min-height:0;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
overflow: hidden;
|
||||
}
|
||||
.pdf-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--card) 95%, var(--text-main) 5%);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.pdf-icon { font-size: 1.25rem; }
|
||||
.pdf-title {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.pdf-filename {
|
||||
margin-left: auto;
|
||||
font-weight: 500;
|
||||
color: var(--text-main);
|
||||
max-width: 50%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.viewer { flex:1 1 auto; display:block; width:100%; height:100%; border:0; min-height:0; }
|
||||
`]
|
||||
})
|
||||
export class PdfViewerComponent {
|
||||
@Input() src: string = '';
|
||||
@Input() path: string = '';
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
|
||||
safeSrc = computed<SafeResourceUrl>(() => this.sanitizer.bypassSecurityTrustResourceUrl(this.src || ''));
|
||||
|
||||
fileName = computed(() => {
|
||||
const p = this.path || this.src || '';
|
||||
try {
|
||||
const u = decodeURI(p);
|
||||
return u.split('/').pop() || u.split('\\').pop() || 'document.pdf';
|
||||
} catch {
|
||||
return p.split('/').pop() || p.split('\\').pop() || 'document.pdf';
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-video-player',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
:host { display:block; height:100%; }
|
||||
.root { height:100%; display:flex; align-items:center; justify-content:center; background: var(--card); border-radius: 12px; overflow:hidden; border:1px solid var(--border); }
|
||||
video { width: 100%; height: 100%; max-height: 90vh; background: black; }
|
||||
`],
|
||||
template: `
|
||||
<div class="root">
|
||||
<video [src]="src" controls playsinline (loadedmetadata)="onMeta($event)"></video>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class VideoPlayerComponent {
|
||||
@Input() src: string = '';
|
||||
@Output() metadata = new EventEmitter<{ width: number; height: number; duration: number }>();
|
||||
|
||||
onMeta(ev: Event) {
|
||||
const v = ev.target as HTMLVideoElement | null;
|
||||
if (!v) return;
|
||||
this.metadata.emit({ width: v.videoWidth || 0, height: v.videoHeight || 0, duration: isFinite(v.duration) ? v.duration : 0 });
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,24 @@
|
||||
|
||||
<ng-container *ngIf="props as current; else emptyState">
|
||||
<div class="space-y-4">
|
||||
<ng-container *ngIf="fileInfo() as fi">
|
||||
<section class="not-prose">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-semibold mb-2">Infos fichier</h4>
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
|
||||
<dt class="text-muted-foreground whitespace-nowrap font-semibold">Type</dt>
|
||||
<dd class="font-medium break-words">{{ fi.type }}</dd>
|
||||
<dt class="text-muted-foreground whitespace-nowrap font-semibold">Taille</dt>
|
||||
<dd class="font-medium break-words">{{ fi.size || 0 | number }} o <span class="text-muted-foreground" *ngIf="fi.sizeHuman">({{ fi.sizeHuman }})</span></dd>
|
||||
<dt class="text-muted-foreground whitespace-nowrap font-semibold">Chemin</dt>
|
||||
<dd class="font-medium break-words">{{ fi.path }}</dd>
|
||||
<dt class="text-muted-foreground whitespace-nowrap font-semibold" *ngIf="fi.createdAt">Créé le</dt>
|
||||
<dd class="font-medium break-words" *ngIf="fi.createdAt">{{ formatDate(fi.createdAt) }}</dd>
|
||||
<dt class="text-muted-foreground whitespace-nowrap font-semibold" *ngIf="fi.modifiedAt">Modifié le</dt>
|
||||
<dd class="font-medium break-words" *ngIf="fi.modifiedAt">{{ formatDate(fi.modifiedAt) }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="hasSummary">
|
||||
<section class="not-prose">
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { Component, EventEmitter, Input, Output, inject, OnChanges, SimpleChanges, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { StateChipComponent } from '../state-chip/state-chip.component';
|
||||
import {
|
||||
@ -9,6 +9,7 @@ import {
|
||||
} from '../../shared/note-properties.model';
|
||||
import { VaultService } from '../../../../../services/vault.service';
|
||||
import { FrontmatterPropertiesService } from '../../shared/frontmatter-properties.service';
|
||||
import { FileTypeDetectorService } from '../../../../../services/file-type-detector.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-properties-popover',
|
||||
@ -16,14 +17,63 @@ import { FrontmatterPropertiesService } from '../../shared/frontmatter-propertie
|
||||
imports: [CommonModule, StateChipComponent],
|
||||
templateUrl: './properties-popover.component.html'
|
||||
})
|
||||
export class PropertiesPopoverComponent {
|
||||
export class PropertiesPopoverComponent implements OnChanges {
|
||||
@Input() props: NoteProperties | null = null;
|
||||
@Input() noteId: string | null = null;
|
||||
private _noteId: string | null = null;
|
||||
@Input() set noteId(value: string | null) {
|
||||
this._noteId = value;
|
||||
// Trigger refresh when input is assigned programmatically by overlay
|
||||
this.refreshFileInfo();
|
||||
}
|
||||
get noteId(): string | null { return this._noteId; }
|
||||
@Output() requestClose = new EventEmitter<void>();
|
||||
@Output() cancelClose = new EventEmitter<void>();
|
||||
|
||||
private vault = inject(VaultService);
|
||||
private frontmatter = inject(FrontmatterPropertiesService);
|
||||
private fileType = inject(FileTypeDetectorService);
|
||||
|
||||
// Lightweight file info for non-markdown
|
||||
readonly fileInfo = signal<{
|
||||
type: string;
|
||||
path: string;
|
||||
size?: number;
|
||||
sizeHuman?: string;
|
||||
createdAt?: string;
|
||||
modifiedAt?: string;
|
||||
} | null>(null);
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['noteId']) {
|
||||
this.refreshFileInfo();
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshFileInfo(): Promise<void> {
|
||||
const id = this.noteId;
|
||||
if (!id) { this.fileInfo.set(null); return; }
|
||||
const note = this.vault.getNoteById(id);
|
||||
if (!note?.filePath) { this.fileInfo.set(null); return; }
|
||||
const type = this.fileType.getViewerType(note.filePath, note.rawContent ?? note.content);
|
||||
if (type === 'markdown' || type === 'excalidraw') { this.fileInfo.set(null); return; }
|
||||
|
||||
const meta = this.vault.getFastMetaByPath(note.filePath);
|
||||
let size: number | undefined;
|
||||
try {
|
||||
const res = await fetch(`/vault/${encodeURI(note.filePath)}`, { method: 'HEAD' });
|
||||
const len = res.headers.get('content-length');
|
||||
size = len ? Number(len) : undefined;
|
||||
} catch {}
|
||||
|
||||
this.fileInfo.set({
|
||||
type,
|
||||
path: note.filePath,
|
||||
size,
|
||||
sizeHuman: this.formatSize(size),
|
||||
createdAt: meta?.createdAt,
|
||||
modifiedAt: meta?.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
private readonly summaryConfig: Array<{
|
||||
key: keyof NotePropertySummary;
|
||||
@ -95,6 +145,15 @@ export class PropertiesPopoverComponent {
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
private formatSize(bytes?: number): string | undefined {
|
||||
if (bytes == null || !Number.isFinite(bytes)) return undefined;
|
||||
const units = ['o','Ko','Mo','Go','To'];
|
||||
let b = Math.max(0, Number(bytes));
|
||||
let i = 0;
|
||||
while (b >= 1024 && i < units.length - 1) { b /= 1024; i++; }
|
||||
return `${b.toFixed(b < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
hasStates(): boolean {
|
||||
return this.stateEntries.length > 0;
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrol
|
||||
import type { VaultNode, TagInfo } from '../../../types';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { VaultService } from '../../../services/vault.service';
|
||||
import { UrlStateService } from '../../services/url-state.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nimbus-sidebar',
|
||||
@ -75,13 +76,39 @@ import { VaultService } from '../../../services/vault.service';
|
||||
<span>📁</span>
|
||||
<span>Folders</span>
|
||||
</button>
|
||||
<button *ngIf="open.folders" (click)="urlState.showAllAndReset()" title="Afficher tous les fichiers et réinitialiser la recherche" class="flex items-center gap-1 p-2 rounded hover:bg-surface1 dark:hover:bg-card mr-1">
|
||||
✨
|
||||
</button>
|
||||
<button *ngIf="open.folders" (click)="onCreateFolderAtRoot()" title="Create Folder" class="flex items-center gap-1 p-2 rounded hover:bg-surface1 dark:hover:bg-card">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 -ml-1" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 010 2h-3v3a1 1 0 01-2 0v-3H6a1 1 0 010-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 010 2h-3v3a1 1 0 01-2 0v-3H6a1 1 0 010-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="open.folders" class="px-1 py-1">
|
||||
<div class="flex gap-2 flex-wrap px-2 pb-2">
|
||||
<button type="button" class="px-2 py-1 rounded text-xs"
|
||||
[ngClass]="chipClass(urlState.isKindActive('image'))"
|
||||
(click)="setKind('image')" title="Images">🖼️</button>
|
||||
<button type="button" class="px-2 py-1 rounded text-xs"
|
||||
[ngClass]="chipClass(urlState.isKindActive('video'))"
|
||||
(click)="setKind('video')" title="Vidéos">🎬</button>
|
||||
<button type="button" class="px-2 py-1 rounded text-xs"
|
||||
[ngClass]="chipClass(urlState.isKindActive('pdf'))"
|
||||
(click)="setKind('pdf')" title="PDF">📄</button>
|
||||
<button type="button" class="px-2 py-1 rounded text-xs"
|
||||
[ngClass]="chipClass(urlState.isKindActive('markdown'))"
|
||||
(click)="setKind('markdown')" title="Markdown">📝</button>
|
||||
<button type="button" class="px-2 py-1 rounded text-xs"
|
||||
[ngClass]="chipClass(urlState.isKindActive('excalidraw'))"
|
||||
(click)="setKind('excalidraw')" title="Excalidraw">✏️</button>
|
||||
<button type="button" class="px-2 py-1 rounded text-xs font-mono"
|
||||
[ngClass]="chipClass(urlState.isKindActive('code'))"
|
||||
(click)="setKind('code')" title="Code"></></button>
|
||||
<button type="button" class="px-2 py-1 rounded text-xs"
|
||||
[ngClass]="chipClass(urlState.isKindActive('all'))"
|
||||
(click)="setKind('all')" title="Tout">✨ Tout</button>
|
||||
</div>
|
||||
<app-file-explorer
|
||||
#foldersExplorer
|
||||
[nodes]="effectiveFileTree"
|
||||
@ -188,8 +215,9 @@ export class NimbusSidebarComponent implements OnChanges {
|
||||
@Output() aboutSelected = new EventEmitter<void>();
|
||||
|
||||
env = environment;
|
||||
open = { quick: true, folders: false, tags: false, trash: false, tests: false };
|
||||
open = { quick: true, folders: true, tags: false, trash: false, tests: false };
|
||||
private vault = inject(VaultService);
|
||||
urlState = inject(UrlStateService);
|
||||
@ViewChild('foldersExplorer') private foldersExplorer?: FileExplorerComponent;
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
@ -245,4 +273,14 @@ export class NimbusSidebarComponent implements OnChanges {
|
||||
this.folderSelected.emit('.trash');
|
||||
}
|
||||
}
|
||||
|
||||
setKind(kind: 'image'|'video'|'pdf'|'markdown'|'excalidraw'|'code'|'all') {
|
||||
this.urlState.filterByKind(kind);
|
||||
}
|
||||
|
||||
chipClass(active: boolean): string {
|
||||
return active
|
||||
? 'bg-primary/15 text-primary ring-1 ring-primary/40'
|
||||
: 'bg-surface1/50 text-muted hover:bg-surface1 dark:hover:bg-card';
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,7 +35,7 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
|
||||
<!-- Fullscreen overlay for note -->
|
||||
<div *ngIf="noteFullScreen && selectedNote && activeView !== 'markdown-playground' && activeView !== 'tests-excalidraw'" class="absolute inset-0 z-50 flex flex-col bg-card dark:bg-main">
|
||||
<div class="note-content-area flex-1 overflow-y-auto px-4 py-4 lg:px-12" appScrollableOverlay>
|
||||
<div class="note-content-area flex-1 overflow-y-auto" appScrollableOverlay>
|
||||
<app-note-viewer
|
||||
[note]="selectedNote || null"
|
||||
[noteHtmlContent]="renderedNoteContent"
|
||||
@ -170,6 +170,7 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
[folderFilter]="folderFilter"
|
||||
[tagFilter]="tagFilter"
|
||||
[quickLinkFilter]="quickLinkFilter"
|
||||
[kindFilter]="urlState.activeKind()"
|
||||
[query]="listQuery"
|
||||
[selectedId]="selectedNoteId"
|
||||
(openNote)="onOpenNote($event)"
|
||||
@ -186,7 +187,7 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
|
||||
<!-- Note View + ToC -->
|
||||
<section class="flex-1 relative min-w-0 flex">
|
||||
<div #pageRoot class="note-content-area flex-1 overflow-y-auto px-4 py-4 lg:px-8" appScrollableOverlay>
|
||||
<div #pageRoot class="note-content-area flex-1 overflow-y-auto" appScrollableOverlay>
|
||||
<app-markdown-playground *ngIf="activeView === 'markdown-playground'"></app-markdown-playground>
|
||||
<app-parameters *ngIf="activeView === 'parameters'"></app-parameters>
|
||||
<app-tests-panel *ngIf="activeView === 'tests-panel'"></app-tests-panel>
|
||||
@ -253,9 +254,9 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
<app-file-explorer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" [foldersOnly]="true" [quickLinkFilter]="quickLinkFilter" (folderSelected)="onFolderSelected($event)" (fileSelected)="onOpenNote($event)"></app-file-explorer>
|
||||
</div>
|
||||
<div [hidden]="mobileNav.activeTab() !== 'list'" class="h-full overflow-y-auto" appScrollableOverlay>
|
||||
<app-notes-list [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [query]="listQuery" [selectedId]="selectedNoteId" (queryChange)="onQueryChange($event)" (openNote)="onOpenNote($event)" (clearQuickLinkFilter)="onClearQuickLinkFilter()" (noteCreated)="onNoteCreated($event)" (noteCreatedAndSelected)="onNoteCreatedAndSelected($event)"></app-notes-list>
|
||||
<app-notes-list [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [kindFilter]="urlState.activeKind()" [query]="listQuery" [selectedId]="selectedNoteId" (queryChange)="onQueryChange($event)" (openNote)="onOpenNote($event)" (clearQuickLinkFilter)="onClearQuickLinkFilter()" (noteCreated)="onNoteCreated($event)" (noteCreatedAndSelected)="onNoteCreatedAndSelected($event)"></app-notes-list>
|
||||
</div>
|
||||
<div [hidden]="mobileNav.activeTab() !== 'page'" class="note-content-area h-full overflow-y-auto px-3 py-4" appScrollableOverlay>
|
||||
<div [hidden]="mobileNav.activeTab() !== 'page'" class="note-content-area h-full overflow-y-auto" appScrollableOverlay>
|
||||
<app-markdown-playground *ngIf="activeView === 'markdown-playground'"></app-markdown-playground>
|
||||
<app-parameters *ngIf="activeView === 'parameters'"></app-parameters>
|
||||
<app-tests-panel *ngIf="activeView === 'tests-panel'"></app-tests-panel>
|
||||
@ -286,29 +287,29 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
|
||||
@if (mobileNav.activeTab() === 'list') {
|
||||
<div class="h-full flex flex-col overflow-hidden animate-fadeIn">
|
||||
<app-notes-list class="flex-1" [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [query]="listQuery" [selectedId]="selectedNoteId" (queryChange)="onQueryChange($event)" (openNote)="onNoteSelectedMobile($event)" (clearQuickLinkFilter)="onClearQuickLinkFilter()"></app-notes-list>
|
||||
<app-notes-list class="flex-1" [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [kindFilter]="urlState.activeKind()" [query]="listQuery" [selectedId]="selectedNoteId" (queryChange)="onQueryChange($event)" (openNote)="onNoteSelectedMobile($event)" (clearQuickLinkFilter)="onClearQuickLinkFilter()"></app-notes-list>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (mobileNav.activeTab() === 'page') {
|
||||
@if (activeView === 'parameters') {
|
||||
<div class="note-content-area h-full px-3 py-3 animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
<div class="note-content-area h-full animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
<app-parameters></app-parameters>
|
||||
</div>
|
||||
} @else if (activeView === 'markdown-playground') {
|
||||
<div class="note-content-area h-full px-3 py-3 animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
<div class="note-content-area h-full animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
<app-markdown-playground></app-markdown-playground>
|
||||
</div>
|
||||
} @else if (activeView === 'tests-panel') {
|
||||
<div class="note-content-area h-full px-3 py-3 animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
<div class="note-content-area h-full animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
<app-tests-panel></app-tests-panel>
|
||||
</div>
|
||||
} @else if (activeView === 'tests-excalidraw') {
|
||||
<div class="note-content-area h-full px-3 py-3 animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
<div class="note-content-area h-full animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
<app-test-excalidraw-page></app-test-excalidraw-page>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="note-content-area h-full px-3 py-3 animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
<div class="note-content-area h-full animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
@if (selectedNote) {
|
||||
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="onTagSelected($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)" (searchRequested)="openInPageSearch()" (fullScreenRequested)="toggleNoteFullScreen()" (parametersRequested)="onParametersOpen()"></app-note-viewer>
|
||||
} @else {
|
||||
|
||||
43
src/app/services/file-viewer-registry.service.ts
Normal file
43
src/app/services/file-viewer-registry.service.ts
Normal 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 [];
|
||||
}
|
||||
@ -15,6 +15,7 @@ export interface UrlState {
|
||||
folder?: string; // Dossier de filtrage
|
||||
quick?: string; // Quick link de filtrage
|
||||
search?: string; // Terme de recherche
|
||||
kind?: 'image' | 'video' | 'pdf' | 'markdown' | 'excalidraw' | 'code' | 'all';
|
||||
}
|
||||
|
||||
export interface UrlStateChangeEvent {
|
||||
@ -120,6 +121,15 @@ export class UrlStateService implements OnDestroy {
|
||||
return search;
|
||||
});
|
||||
|
||||
/**
|
||||
* Filtre de type de fichier actif
|
||||
*/
|
||||
readonly activeKind = computed(() => {
|
||||
const k = this.currentStateSignal().kind || null;
|
||||
console.log('🧩 activeKind():', k);
|
||||
return k;
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// CONSTRUCTOR & LIFECYCLE
|
||||
// ========================================
|
||||
@ -138,6 +148,7 @@ export class UrlStateService implements OnDestroy {
|
||||
const v = qpm.get(k);
|
||||
if (v !== null) params[k] = v;
|
||||
}
|
||||
|
||||
// Fallback: at first load with certain servers, Router may expose empty params
|
||||
if (Object.keys(params).length === 0 && typeof window !== 'undefined') {
|
||||
const sp = new URLSearchParams(window.location.search || '');
|
||||
@ -164,6 +175,26 @@ export class UrlStateService implements OnDestroy {
|
||||
this.previousStateSignal.set(previousState);
|
||||
this.currentStateSignal.set(newState);
|
||||
this.stateChangeSubject.next({ previous: previousState, current: newState, changed });
|
||||
|
||||
// If the active list section changed (tag/folder/quick), normalize URL:
|
||||
// - Keep only the new section key
|
||||
// - Clear others (including search), reset kind to 'all', and clear opened note
|
||||
const prevSection = this.getActiveSection(previousState);
|
||||
const nextSection = this.getActiveSection(newState);
|
||||
if (prevSection !== nextSection && nextSection !== null) {
|
||||
// Schedule to avoid interfering within the same router tick
|
||||
setTimeout(() => {
|
||||
const partial: Partial<UrlState> = {
|
||||
tag: nextSection === 'tag' ? newState.tag! : null,
|
||||
folder: nextSection === 'folder' ? newState.folder! : null,
|
||||
quick: nextSection === 'quick' ? newState.quick! : null,
|
||||
search: null,
|
||||
kind: 'all',
|
||||
note: null,
|
||||
};
|
||||
this.updateUrl(partial);
|
||||
}, 0);
|
||||
}
|
||||
} else if (!previousState || Object.keys(previousState).length === 0) {
|
||||
// Première initialisation si nécessaire
|
||||
console.log('🌐 UrlStateService - first initialization');
|
||||
@ -233,6 +264,16 @@ export class UrlStateService implements OnDestroy {
|
||||
console.log('✅ parseUrlParams - search set:', state.search);
|
||||
}
|
||||
|
||||
// Kind (toujours en plus)
|
||||
if (params['kind']) {
|
||||
const raw = decodeURIComponent(params['kind']).toLowerCase();
|
||||
const allowed = ['image','video','pdf','markdown','excalidraw','code','all'];
|
||||
if (allowed.includes(raw)) {
|
||||
state.kind = raw as UrlState['kind'];
|
||||
console.log('✅ parseUrlParams - kind set:', state.kind);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎯 parseUrlParams final state:', state);
|
||||
return state;
|
||||
}
|
||||
@ -243,7 +284,7 @@ export class UrlStateService implements OnDestroy {
|
||||
private detectChanges(previous: UrlState, current: UrlState): (keyof UrlState)[] {
|
||||
const changed: (keyof UrlState)[] = [];
|
||||
|
||||
const keys: (keyof UrlState)[] = ['note', 'tag', 'folder', 'quick', 'search'];
|
||||
const keys: (keyof UrlState)[] = ['note', 'tag', 'folder', 'quick', 'search', 'kind'];
|
||||
|
||||
for (const key of keys) {
|
||||
if (previous[key] !== current[key]) {
|
||||
@ -254,6 +295,16 @@ export class UrlStateService implements OnDestroy {
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la section active parmi tag/folder/quick, sinon null
|
||||
*/
|
||||
private getActiveSection(state: UrlState): 'tag' | 'folder' | 'quick' | null {
|
||||
if (state.tag) return 'tag';
|
||||
if (state.folder) return 'folder';
|
||||
if (state.quick) return 'quick';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// STATE UPDATES
|
||||
// ========================================
|
||||
@ -348,6 +399,14 @@ export class UrlStateService implements OnDestroy {
|
||||
await this.updateUrl({ search: searchTerm || undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtrer par type de fichier (image, video, pdf, markdown, excalidraw, code, all)
|
||||
*/
|
||||
async filterByKind(kind: UrlState['kind']): Promise<void> {
|
||||
const normalized = (kind || 'all');
|
||||
await this.updateUrl({ kind: normalized });
|
||||
}
|
||||
|
||||
/**
|
||||
* Définir la note et optionnellement le dossier (pour création de note)
|
||||
* Utilise merge pour conserver les autres paramètres (search, etc.)
|
||||
@ -418,6 +477,7 @@ export class UrlStateService implements OnDestroy {
|
||||
if (stateToShare.folder) params.set('folder', stateToShare.folder);
|
||||
if (stateToShare.quick) params.set('quick', stateToShare.quick);
|
||||
if (stateToShare.search) params.set('search', stateToShare.search);
|
||||
if (stateToShare.kind) params.set('kind', stateToShare.kind);
|
||||
|
||||
const baseUrl = window.location.origin + window.location.pathname;
|
||||
return `${baseUrl}?${params.toString()}`;
|
||||
@ -436,6 +496,13 @@ export class UrlStateService implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show all files and clear contextual filters/search so the next search applies globally.
|
||||
*/
|
||||
async showAllAndReset(): Promise<void> {
|
||||
await this.updateUrl({ tag: null, folder: null, quick: null, search: null, kind: 'all', note: null });
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// OBSERVABLES & EVENTS
|
||||
// ========================================
|
||||
@ -488,6 +555,13 @@ export class UrlStateService implements OnDestroy {
|
||||
return this.currentStateSignal().quick === quickLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier si un type est actif
|
||||
*/
|
||||
isKindActive(kind: UrlState['kind']): boolean {
|
||||
return (this.currentStateSignal().kind || 'all') === (kind || 'all');
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir l'état actuel (snapshot)
|
||||
*/
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import { Component, Input, OnChanges, SimpleChanges, signal, computed, inject, CUSTOM_ELEMENTS_SCHEMA, ViewContainerRef, ComponentRef, ViewChild, effect } from '@angular/core';
|
||||
import { Component, Input, OnChanges, SimpleChanges, signal, computed, inject, CUSTOM_ELEMENTS_SCHEMA, ViewContainerRef, ComponentRef, ViewChild, effect, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MarkdownViewerComponent } from '../markdown-viewer/markdown-viewer.component';
|
||||
import { FileTypeDetectorService } from '../../services/file-type-detector.service';
|
||||
import { Note } from '../../types';
|
||||
import { EditorStateService } from '../../services/editor-state.service';
|
||||
import { DrawingsEditorComponent } from '../../app/features/drawings/drawings-editor.component';
|
||||
import { PdfViewerComponent } from '../../app/features/note-view/components/pdf-viewer/pdf-viewer.component';
|
||||
import { CodeRendererComponent } from '../../app/features/note-view/components/code-renderer/code-renderer.component';
|
||||
import { ImageViewerComponent } from '../../app/features/note-view/components/image-viewer/image-viewer.component';
|
||||
import { VideoPlayerComponent } from '../../app/features/note-view/components/video-player/video-player.component';
|
||||
|
||||
/**
|
||||
* Composant intelligent qui détecte automatiquement le type de fichier
|
||||
@ -22,7 +27,7 @@ import { EditorStateService } from '../../services/editor-state.service';
|
||||
@Component({
|
||||
selector: 'app-smart-file-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MarkdownViewerComponent],
|
||||
imports: [CommonModule, MarkdownViewerComponent, DrawingsEditorComponent, PdfViewerComponent, CodeRendererComponent, ImageViewerComponent, VideoPlayerComponent],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
template: `
|
||||
<div class="smart-file-viewer" [attr.data-viewer-type]="viewerType()">
|
||||
@ -31,41 +36,53 @@ import { EditorStateService } from '../../services/editor-state.service';
|
||||
|
||||
<!-- Markdown/Excalidraw Viewer (Read Mode) -->
|
||||
<app-markdown-viewer
|
||||
*ngIf="!isEditMode() && (viewerType() === 'markdown' || viewerType() === 'excalidraw')"
|
||||
*ngIf="!isEditMode() && viewerType() === 'markdown'"
|
||||
[content]="content"
|
||||
[allNotes]="allNotes"
|
||||
[currentNote]="currentNote"
|
||||
[showToolbar]="showToolbar"
|
||||
[fullscreenMode]="fullscreenMode"
|
||||
[filePath]="filePath"
|
||||
(editModeRequested)="onEditModeRequested($event)">
|
||||
(editModeRequested)="onEditModeRequested($event)"
|
||||
class="animate-fadeIn">
|
||||
</app-markdown-viewer>
|
||||
|
||||
<!-- Excalidraw Viewer -->
|
||||
<div *ngIf="!isEditMode() && viewerType() === 'excalidraw'" class="smart-file-viewer__excalidraw animate-fadeIn">
|
||||
<app-drawings-editor
|
||||
class="w-full h-full flex-1 min-h-0"
|
||||
[path]="filePath"
|
||||
[showInlineActions]="true"
|
||||
></app-drawings-editor>
|
||||
</div>
|
||||
|
||||
<!-- Image Viewer -->
|
||||
<div *ngIf="viewerType() === 'image'" class="smart-file-viewer__image">
|
||||
<img
|
||||
[src]="imageSrc()"
|
||||
[alt]="fileName()"
|
||||
class="max-w-full h-auto rounded-lg shadow-lg"
|
||||
loading="lazy">
|
||||
<div *ngIf="viewerType() === 'image'" class="smart-file-viewer__image animate-fadeIn">
|
||||
<app-image-viewer [src]="imageSrc()" [alt]="fileName()" (dimensions)="onImgDims($event)"></app-image-viewer>
|
||||
</div>
|
||||
|
||||
<!-- PDF Viewer -->
|
||||
<div *ngIf="viewerType() === 'pdf'" class="smart-file-viewer__pdf">
|
||||
<iframe
|
||||
[src]="pdfSrc()"
|
||||
class="w-full h-full border-0"
|
||||
title="PDF Viewer">
|
||||
</iframe>
|
||||
<div *ngIf="viewerType() === 'pdf'" class="smart-file-viewer__pdf animate-fadeIn">
|
||||
<app-pdf-viewer class="w-full h-full flex-1 min-h-0" [src]="pdfSrc()" [path]="filePath"></app-pdf-viewer>
|
||||
</div>
|
||||
|
||||
<!-- Video Viewer -->
|
||||
<div *ngIf="viewerType() === 'video'" class="smart-file-viewer__video animate-fadeIn">
|
||||
<app-video-player [src]="videoSrc()" (metadata)="onVidMeta($event)"></app-video-player>
|
||||
</div>
|
||||
|
||||
<!-- Code Viewer -->
|
||||
<div *ngIf="viewerType() === 'code'" class="smart-file-viewer__text animate-fadeIn w-full h-full">
|
||||
<app-code-renderer [path]="filePath" [content]="resolvedContent()" />
|
||||
</div>
|
||||
|
||||
<!-- Text Viewer -->
|
||||
<div *ngIf="viewerType() === 'text'" class="smart-file-viewer__text">
|
||||
<pre class="p-4 bg-card rounded-lg overflow-auto"><code>{{ content }}</code></pre>
|
||||
<div *ngIf="viewerType() === 'text'" class="smart-file-viewer__text animate-fadeIn">
|
||||
<pre class="p-4 bg-card rounded-lg overflow-auto"><code>{{ resolvedContent() }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- Unknown File Type -->
|
||||
<div *ngIf="viewerType() === 'unknown'" class="smart-file-viewer__unknown">
|
||||
<div *ngIf="viewerType() === 'unknown'" class="smart-file-viewer__unknown animate-fadeIn">
|
||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||
<svg class="w-16 h-16 text-muted mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
@ -80,12 +97,17 @@ import { EditorStateService } from '../../services/editor-state.service';
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.smart-file-viewer {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.smart-file-viewer__editor {
|
||||
@ -105,14 +127,42 @@ import { EditorStateService } from '../../services/editor-state.service';
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Excalidraw should stretch exactly like PDF */
|
||||
.smart-file-viewer__excalidraw {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.smart-file-viewer__excalidraw app-drawings-editor,
|
||||
.smart-file-viewer__pdf app-pdf-viewer {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.smart-file-viewer__pdf {
|
||||
padding: 0;
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.smart-file-viewer__video {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.smart-file-viewer__image img {
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.smart-file-viewer__pdf iframe {
|
||||
min-height: 600px;
|
||||
}
|
||||
/* Removed min-height on PDF iframe to let it stretch with flex */
|
||||
|
||||
.smart-file-viewer__text pre {
|
||||
width: 100%;
|
||||
@ -125,11 +175,14 @@ import { EditorStateService } from '../../services/editor-state.service';
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.smart-file-viewer__image,
|
||||
.smart-file-viewer__pdf,
|
||||
.smart-file-viewer__text,
|
||||
.smart-file-viewer__unknown {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.smart-file-viewer__pdf {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
@ -139,42 +192,62 @@ export class SmartFileViewerComponent implements OnChanges {
|
||||
private fileTypeDetector = inject(FileTypeDetectorService);
|
||||
private editorStateService = inject(EditorStateService);
|
||||
private editorComponentRef?: ComponentRef<any>;
|
||||
private codeComponentRef?: ComponentRef<any>;
|
||||
|
||||
@Input() filePath: string = '';
|
||||
@Input() content: string = '';
|
||||
private filePathSig = signal<string>('');
|
||||
private contentSig = signal<string>('');
|
||||
@Input() allNotes: Note[] = [];
|
||||
@Input() currentNote?: Note;
|
||||
@Input() showToolbar: boolean = true;
|
||||
@Input() fullscreenMode: boolean = true;
|
||||
@Output() imageDimensions = new EventEmitter<{ width: number; height: number }>();
|
||||
@Output() videoMetadata = new EventEmitter<{ width: number; height: number; duration: number }>();
|
||||
|
||||
viewerType = signal<'markdown' | 'excalidraw' | 'image' | 'pdf' | 'text' | 'unknown'>('unknown');
|
||||
viewerType = signal<'markdown' | 'excalidraw' | 'image' | 'pdf' | 'video' | 'code' | 'text' | 'unknown'>('unknown');
|
||||
isEditMode = computed(() => this.editorStateService.isEditMode());
|
||||
|
||||
fileName = computed(() => {
|
||||
return this.filePath.split('/').pop() || this.filePath.split('\\').pop() || 'Unknown file';
|
||||
const p = this.filePathSig();
|
||||
return p.split('/').pop() || p.split('\\').pop() || 'Unknown file';
|
||||
});
|
||||
|
||||
imageSrc = computed(() => {
|
||||
if (this.viewerType() !== 'image') return '';
|
||||
|
||||
// If content is base64, use it directly
|
||||
if (this.content.startsWith('data:image')) {
|
||||
return this.content;
|
||||
const c = this.contentSig();
|
||||
if (c.startsWith('data:image')) {
|
||||
return c;
|
||||
}
|
||||
|
||||
// Otherwise, construct API path
|
||||
return `/api/files/${encodeURIComponent(this.filePath)}`;
|
||||
// Otherwise, serve directly from vault static path
|
||||
return `/vault/${encodeURI(this.filePathSig())}`;
|
||||
});
|
||||
|
||||
pdfSrc = computed(() => {
|
||||
if (this.viewerType() !== 'pdf') return '';
|
||||
return `/api/files/${encodeURIComponent(this.filePath)}`;
|
||||
const path = encodeURI(this.filePathSig());
|
||||
return `/vault/${path}#view=FitH&zoom=page-width`;
|
||||
});
|
||||
|
||||
videoSrc = computed(() => {
|
||||
if (this.viewerType() !== 'video') return '';
|
||||
return `/vault/${encodeURI(this.filePathSig())}`;
|
||||
});
|
||||
|
||||
// Internal fetched content for non-markdown text/code files
|
||||
private fetchedContent = signal<string>('');
|
||||
resolvedContent = computed(() => {
|
||||
const c = this.contentSig();
|
||||
return (c && c.length > 0) ? c : this.fetchedContent();
|
||||
});
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['filePath'] || changes['content']) {
|
||||
this.detectViewerType();
|
||||
}
|
||||
if (changes['filePath']) this.filePathSig.set(this.filePath || '');
|
||||
if (changes['content']) this.contentSig.set(this.content || '');
|
||||
if (changes['filePath'] || changes['content']) this.detectViewerType();
|
||||
}
|
||||
|
||||
private detectViewerType(): void {
|
||||
@ -192,6 +265,29 @@ export class SmartFileViewerComponent implements OnChanges {
|
||||
this.unloadEditor();
|
||||
}
|
||||
});
|
||||
|
||||
// No-op for code lazy loading: now using inline CodeRendererComponent
|
||||
|
||||
// Fetch textual content for code/text viewers when filePath or viewer changes
|
||||
effect(() => {
|
||||
const vt = this.viewerType();
|
||||
const path = this.filePathSig();
|
||||
if (!path) { this.fetchedContent.set(''); return; }
|
||||
if (vt === 'code' || vt === 'text') {
|
||||
// Do not attempt to fetch files inside the .obsidian folder (restricted)
|
||||
if (path.startsWith('.obsidian/')) {
|
||||
this.fetchedContent.set('');
|
||||
} else {
|
||||
const url = `/vault/${encodeURI(path)}`;
|
||||
fetch(url)
|
||||
.then(r => r.text().catch(() => ''))
|
||||
.then(txt => this.fetchedContent.set(txt))
|
||||
.catch(() => this.fetchedContent.set(''));
|
||||
}
|
||||
} else {
|
||||
this.fetchedContent.set('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async onEditModeRequested(event: { path: string; content: string }): Promise<void> {
|
||||
@ -241,4 +337,8 @@ export class SmartFileViewerComponent implements OnChanges {
|
||||
|
||||
console.log('[SmartFileViewer] Editor unloaded');
|
||||
}
|
||||
|
||||
// Bridge child viewer events to parent consumers
|
||||
onImgDims(ev: { width: number; height: number }) { this.imageDimensions.emit(ev); }
|
||||
onVidMeta(ev: { width: number; height: number; duration: number }) { this.videoMetadata.emit(ev); }
|
||||
}
|
||||
|
||||
@ -1,56 +1,24 @@
|
||||
@if(note(); as note) {
|
||||
<div class="mx-auto flex max-w-7xl">
|
||||
<div class="flex flex-col w-full h-full">
|
||||
@if(note.type === 'pdf') {
|
||||
<app-pdf-viewer
|
||||
class="flex-1 w-full h-full"
|
||||
[src]="note.filePath">
|
||||
</app-pdf-viewer>
|
||||
} @else {
|
||||
<div class="mx-auto flex max-w-7xl w-full h-full">
|
||||
<div class="flex-grow min-w-0 max-w-4xl px-6 py-8 lg:px-12 lg:py-12">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-4xl font-bold text-text-main mb-2">{{ note.title }}</h1>
|
||||
<div class="text-sm text-text-muted">
|
||||
<span>Mise à jour : {{ note.updatedAt | date:'medium' }}</span>
|
||||
</div>
|
||||
<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>
|
||||
}
|
||||
|
||||
@ -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">
|
||||
@ -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(
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
export interface FileTypeInfo {
|
||||
type: 'markdown' | 'excalidraw' | 'image' | 'pdf' | 'unknown';
|
||||
type: 'markdown' | 'excalidraw' | 'image' | 'pdf' | 'video' | 'code' | 'unknown';
|
||||
isEditable: boolean;
|
||||
requiresSpecialViewer: boolean;
|
||||
icon: string;
|
||||
@ -52,6 +52,29 @@ export class FileTypeDetectorService {
|
||||
return path.toLowerCase().endsWith('.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte si un fichier est une vidéo
|
||||
*/
|
||||
isVideoFile(path: string): boolean {
|
||||
if (!path) return false;
|
||||
const normalized = path.toLowerCase();
|
||||
const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm'];
|
||||
return videoExtensions.some(ext => normalized.endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte si un fichier est un fichier de code/texte structuré
|
||||
*/
|
||||
isCodeFile(path: string): boolean {
|
||||
if (!path) return false;
|
||||
const normalized = path.toLowerCase();
|
||||
const codeExt = [
|
||||
'.json','.py','.java','.ts','.js','.mjs','.cjs','.cs','.cpp','.c','.hpp','.h','.go','.rs','.rb','.php','.kt','.kts','.scala','.swift','.sh','.bash','.ps1','.zsh','.yml','.yaml','.toml','.ini','.conf','.cfg','.env','.xml','.xsd','.xslt','.sql','.dockerfile','.makefile','.gradle','.cmake','.tex','.r','.jl','.lua',
|
||||
'.html','.css','.jsx','.tsx'
|
||||
];
|
||||
return codeExt.some(ext => normalized.endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les informations complètes sur le type de fichier
|
||||
*/
|
||||
@ -97,6 +120,25 @@ export class FileTypeDetectorService {
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isVideoFile(path)) {
|
||||
return {
|
||||
type: 'video',
|
||||
isEditable: false,
|
||||
requiresSpecialViewer: false,
|
||||
icon: '🎬',
|
||||
mimeType: 'video/*'
|
||||
};
|
||||
}
|
||||
|
||||
if (this.isCodeFile(path)) {
|
||||
return {
|
||||
type: 'code',
|
||||
isEditable: false,
|
||||
requiresSpecialViewer: true,
|
||||
icon: '</>'
|
||||
} as any;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'unknown',
|
||||
isEditable: false,
|
||||
@ -154,7 +196,7 @@ export class FileTypeDetectorService {
|
||||
/**
|
||||
* Détermine le viewer approprié pour un fichier
|
||||
*/
|
||||
getViewerType(path: string, content?: string): 'markdown' | 'excalidraw' | 'image' | 'pdf' | 'text' | 'unknown' {
|
||||
getViewerType(path: string, content?: string): 'markdown' | 'excalidraw' | 'image' | 'pdf' | 'video' | 'code' | 'text' | 'unknown' {
|
||||
// Check file extension first
|
||||
if (this.isExcalidrawFile(path)) {
|
||||
return 'excalidraw';
|
||||
@ -180,8 +222,16 @@ export class FileTypeDetectorService {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
if (this.isVideoFile(path)) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (this.isCodeFile(path)) {
|
||||
return 'code';
|
||||
}
|
||||
|
||||
// Check if it's a text file
|
||||
const textExtensions = ['.txt', '.json', '.xml', '.csv', '.log', '.yaml', '.yml', '.toml'];
|
||||
const textExtensions = ['.txt', '.csv', '.log'];
|
||||
if (textExtensions.some(ext => path.toLowerCase().endsWith(ext))) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
@ -267,7 +267,40 @@ export class VaultService implements OnDestroy {
|
||||
}
|
||||
|
||||
getNoteById(id: string): Note | undefined {
|
||||
return this.notesMap().get(id);
|
||||
const existing = this.notesMap().get(id);
|
||||
if (existing) return existing;
|
||||
// Fallback: synthesize a virtual note from fast metadata (for non-markdown files)
|
||||
const path = this.idToPathIndex.get(id) || this.slugIdToPathIndex.get(id);
|
||||
const meta = path ? this.metaByPathIndex.get(path) : undefined;
|
||||
if (!meta || !path) return undefined;
|
||||
|
||||
const filePath = this.normalizePath(path);
|
||||
const fileName = filePath.split('/').pop() || filePath;
|
||||
const originalPath = filePath.replace(/\.md$/i, '').replace(/\\/g, '/');
|
||||
const title = meta.title || fileName;
|
||||
const createdAt = meta.createdAt || undefined;
|
||||
const updatedAt = meta.updatedAt || undefined;
|
||||
|
||||
const virtualNote: Note = {
|
||||
id,
|
||||
title,
|
||||
content: '',
|
||||
rawContent: '',
|
||||
tags: [],
|
||||
frontmatter: {} as any,
|
||||
backlinks: [],
|
||||
mtime: Date.parse(updatedAt || createdAt || '') || 0,
|
||||
fileName,
|
||||
filePath,
|
||||
originalPath,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
// Cache synthesized note to make it selectable
|
||||
const next = new Map(this.notesMap());
|
||||
next.set(id, virtualNote);
|
||||
this.notesMap.set(next);
|
||||
return virtualNote;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -327,7 +360,36 @@ export class VaultService implements OnDestroy {
|
||||
if (!id || this.getNoteById(id)) return !!id;
|
||||
|
||||
const path = this.idToPathIndex.get(id) || this.slugIdToPathIndex.get(id);
|
||||
return path ? this.ensureNoteLoadedByPath(path) : false;
|
||||
if (!path) return false;
|
||||
// Try to load real markdown content; if not possible, synthesize virtual note
|
||||
const ok = await this.ensureNoteLoadedByPath(path);
|
||||
if (ok) return true;
|
||||
const meta = this.metaByPathIndex.get(path);
|
||||
if (!meta) return false;
|
||||
// Create a virtual note for non-markdown types
|
||||
const filePath = this.normalizePath(path);
|
||||
const fileName = filePath.split('/').pop() || filePath;
|
||||
const originalPath = filePath.replace(/\.md$/i, '').replace(/\\/g, '/');
|
||||
const title = meta.title || fileName;
|
||||
const createdAt = meta.createdAt || undefined;
|
||||
const updatedAt = meta.updatedAt || undefined;
|
||||
const virtualNote: Note = {
|
||||
id,
|
||||
title,
|
||||
content: '',
|
||||
rawContent: '',
|
||||
tags: [],
|
||||
frontmatter: {} as any,
|
||||
backlinks: [],
|
||||
mtime: Date.parse(updatedAt || createdAt || '') || 0,
|
||||
fileName,
|
||||
filePath,
|
||||
originalPath,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
this.addNoteToMap(virtualNote);
|
||||
return true;
|
||||
}
|
||||
|
||||
async ensureNoteLoadedByPath(path: string): Promise<boolean> {
|
||||
@ -343,6 +405,7 @@ export class VaultService implements OnDestroy {
|
||||
this.addNoteToMap(note);
|
||||
return true;
|
||||
} catch {
|
||||
// Non-markdown or unsupported types will fail here; signal caller to synthesize
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -395,6 +458,12 @@ export class VaultService implements OnDestroy {
|
||||
return path ? this.metaByPathIndex.get(path) : undefined;
|
||||
}
|
||||
|
||||
getFastMetaByPath(filePath: string): FileMetadata | undefined {
|
||||
if (!filePath) return undefined;
|
||||
const p = this.normalizePath(filePath);
|
||||
return this.metaByPathIndex.get(p);
|
||||
}
|
||||
|
||||
async updateNoteTags(noteId: string, tags: string[]): Promise<boolean> {
|
||||
const note = this.getNoteById(noteId);
|
||||
if (!note?.filePath) return false;
|
||||
@ -589,12 +658,16 @@ export class VaultService implements OnDestroy {
|
||||
}
|
||||
|
||||
private buildTrashTree(): VaultNode[] {
|
||||
// Touch fast tree to make this computed reactive to file metadata updates
|
||||
const _fast = this.fastFileTree();
|
||||
|
||||
const root = this.createFolder(TRASH_FOLDER, TRASH_FOLDER, true);
|
||||
const folders = new Map<string, VaultFolder>([[TRASH_FOLDER, root]]);
|
||||
const openFolders = this.openFolderPaths();
|
||||
|
||||
for (const note of this.allNotes()) {
|
||||
const filePath = this.normalizePath(note.filePath || note.originalPath || '');
|
||||
// Include ALL files (not only markdown notes) from metadata index
|
||||
for (const [pathKey] of this.metaByPathIndex.entries()) {
|
||||
const filePath = this.normalizePath(pathKey);
|
||||
if (!this.isInTrash(filePath)) continue;
|
||||
|
||||
const segments = this.parseTrashFolderSegments(filePath);
|
||||
@ -602,7 +675,9 @@ export class VaultService implements OnDestroy {
|
||||
? this.ensureTrashFolderPath(segments, folders, root, openFolders)
|
||||
: root;
|
||||
|
||||
this.addFileNode(parentFolder, note.filePath, note.id, note.fileName);
|
||||
const id = this.buildSlugIdFromPath(filePath);
|
||||
const fileName = filePath.split('/').pop() || filePath;
|
||||
this.addFileNode(parentFolder, filePath, id, fileName);
|
||||
}
|
||||
|
||||
this.sortAndCleanFolderChildren(root);
|
||||
@ -706,14 +781,18 @@ export class VaultService implements OnDestroy {
|
||||
}
|
||||
|
||||
private calculateFolderCounts(): Record<string, number> {
|
||||
// Touch fast tree to make this computed reactive to file metadata updates
|
||||
const _fast = this.fastFileTree();
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (const note of this.allNotes()) {
|
||||
const path = this.normalizePath(note.originalPath || note.filePath || '');
|
||||
// Count ALL files from metadata index (fast, includes non-markdown)
|
||||
for (const [pathKey] of this.metaByPathIndex.entries()) {
|
||||
const path = this.normalizePath(pathKey);
|
||||
if (!path || this.isInTrash(path) || this.isBuiltinPath(path)) continue;
|
||||
|
||||
const parts = path.split('/');
|
||||
parts.pop(); // Remove filename
|
||||
parts.pop(); // remove filename
|
||||
|
||||
let acc = '';
|
||||
for (const segment of parts) {
|
||||
@ -727,6 +806,9 @@ export class VaultService implements OnDestroy {
|
||||
}
|
||||
|
||||
private calculateTrashFolderCounts(): Record<string, number> {
|
||||
// Touch fast tree to make this computed reactive to file metadata updates
|
||||
const _fast = this.fastFileTree();
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
const increment = (path: string) => {
|
||||
@ -735,8 +817,9 @@ export class VaultService implements OnDestroy {
|
||||
counts[norm] = (counts[norm] ?? 0) + 1;
|
||||
};
|
||||
|
||||
for (const note of this.allNotes()) {
|
||||
const filePath = this.normalizePath(note.filePath || note.originalPath || '');
|
||||
// Count ALL files from metadata index that are in trash
|
||||
for (const [pathKey] of this.metaByPathIndex.entries()) {
|
||||
const filePath = this.normalizePath(pathKey);
|
||||
if (!this.isInTrash(filePath)) continue;
|
||||
|
||||
const segments = this.parseTrashFolderSegments(filePath);
|
||||
@ -750,6 +833,7 @@ export class VaultService implements OnDestroy {
|
||||
|
||||
let current = TRASH_FOLDER;
|
||||
for (const segment of segments) {
|
||||
if (!segment) continue;
|
||||
current = `${current}/${segment}`;
|
||||
increment(current);
|
||||
}
|
||||
|
||||
@ -636,6 +636,20 @@ html, body {
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Full-bleed container for non-markdown viewers */
|
||||
.ov-fullbleed {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
/* Ensure viewer wrappers are never constrained by inherited max-width */
|
||||
.ov-viewer,
|
||||
.ov-pdf,
|
||||
.ov-excalidraw {
|
||||
max-width: none !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Mobile animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
|
||||
27
vault/.obsidian/workspace.json
vendored
27
vault/.obsidian/workspace.json
vendored
@ -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"
|
||||
]
|
||||
}
|
||||
15
vault/Dessin-02.excalidraw.md
Normal file
15
vault/Dessin-02.excalidraw.md
Normal 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
|
||||
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=
|
||||
```
|
||||
%%
|
||||
BIN
vault/Dessin-02.png
Normal file
BIN
vault/Dessin-02.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
@ -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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
|
||||
```
|
||||
%%
|
||||
@ -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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
|
||||
```
|
||||
%%
|
||||
BIN
vault/dessin.png
BIN
vault/dessin.png
Binary file not shown.
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 2.8 KiB |
BIN
vault/mixe/Claude_ObsiViewer_V1.png
Normal file
BIN
vault/mixe/Claude_ObsiViewer_V1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 946 KiB |
72
vault/mixe/ExampleScript.ps1
Normal file
72
vault/mixe/ExampleScript.ps1
Normal 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 d’un 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."
|
||||
BIN
vault/mixe/Relaxing Music relax music music _hls-480_.mp4
Normal file
BIN
vault/mixe/Relaxing Music relax music music _hls-480_.mp4
Normal file
Binary file not shown.
BIN
vault/mixe/ThinkBook_16_G7_ARP_Spec.pdf
Normal file
BIN
vault/mixe/ThinkBook_16_G7_ARP_Spec.pdf
Normal file
Binary file not shown.
217
vault/mixe/dessin.json
Normal file
217
vault/mixe/dessin.json
Normal 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": {}
|
||||
}
|
||||
4
vault/mixe/image_no_bg_clean.svg
Normal file
4
vault/mixe/image_no_bg_clean.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 825 KiB |
61
vault/mixe/test.py
Normal file
61
vault/mixe/test.py
Normal 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()
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user