feat: revamp editor UI and enhance about panel

- Updated markdown editor with modern toolbar design and improved visual hierarchy
- Added auto-save indicator and note color support in editor header
- Redesigned note list cards with hover effects and quick action buttons
- Added version, build date and copyright info to about panel
- Enhanced scrollbar styling and padding in editor container
- Improved responsive layout and dark mode support across components
This commit is contained in:
Bruno Charest 2025-10-27 10:11:20 -04:00
parent 917af04642
commit 11a58426d0
44 changed files with 1781 additions and 588 deletions

View File

@ -78,6 +78,10 @@ import { trigger, transition, style, animate } from '@angular/animations';
<p class="text-sm text-muted">
Créé par <span class="font-semibold text-main dark:text-white">Bruno Charest</span>
</p>
<div class="mt-1 text-xs text-muted">
<div>Version: {{ version }}</div>
<div>Date de compilation: {{ buildDate }}</div>
</div>
</div>
<!-- Credits -->
@ -104,6 +108,7 @@ import { trigger, transition, style, animate } from '@angular/animations';
</svg>
Voir sur GitHub
</a>
<div class="mt-4 text-[11px] text-muted">© {{ currentYear }} ObsiViewer. Tous droits réservés.</div>
</div>
</div>
</div>
@ -119,6 +124,8 @@ export class AboutPanelComponent {
@Output() close = new EventEmitter<void>();
readonly version = '1.0.0';
readonly buildDate = new Date().toLocaleString();
readonly currentYear = new Date().getFullYear();
@HostListener('document:keydown.escape')
onEscapeKey(): void {

View File

@ -43,84 +43,25 @@ import { EditorHighlightService } from '../../shared/editor/editor-highlight.ser
imports: [CommonModule],
template: `
<div class="markdown-editor" [class.markdown-editor--dark]="isDarkTheme()">
<!-- Toolbar -->
<div class="markdown-editor__toolbar">
<div class="markdown-editor__toolbar-left">
<span class="text-sm font-medium text-muted">Editing</span>
<span class="text-sm text-muted-foreground" *ngIf="filePath()">{{ fileName() }}</span>
<!-- Revamped Toolbar -->
<header
class="flex items-center justify-between px-4 py-2 bg-gradient-to-r from-[#0f172a] to-[#1e293b] border-b border-slate-700/50 shadow-sm backdrop-blur-sm relative"
[style.border-top]="noteColor() ? '3px solid ' + noteColor() : 'none'"
>
<h1 class="text-slate-200 font-semibold text-base">{{ fileName() }}</h1>
<div class="flex items-center gap-2">
<div class="relative">
<button class="px-3 py-1 rounded-lg bg-white/10 hover:bg-white/20 text-slate-200 transition" (click)="save()" [disabled]="isSaving()">
{{ isSaving() ? 'Saving...' : 'Save' }}
</button>
<span *ngIf="isAutoSaving()" class="absolute top-0 right-0 -mt-1 -mr-1 w-3 h-3 bg-green-500 rounded-full animate-ping"></span>
<span *ngIf="isAutoSaving()" class="absolute top-0 right-0 -mt-1 -mr-1 w-3 h-3 bg-green-500 rounded-full"></span>
</div>
<button class="px-3 py-1 rounded-lg bg-white/10 hover:bg-white/20 text-slate-200 transition" (click)="toggleWordWrap()">Wrap</button>
<button class="px-3 py-1 rounded-lg bg-white/10 hover:bg-white/20 text-slate-200 transition" (click)="undo()">Undo</button>
<button class="px-3 py-1 rounded-lg bg-red-500/20 hover:bg-red-500/40 text-red-200 transition" (click)="close()">Close</button>
</div>
<div class="markdown-editor__toolbar-right">
<!-- Save Button -->
<button
type="button"
class="editor-btn editor-btn--primary"
[disabled]="isSaving()"
(click)="save()"
[attr.aria-label]="'Save (Ctrl+S)'">
<svg *ngIf="!isSaving()" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="editor-btn__icon">
<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>
<svg *ngIf="isSaving()" class="animate-spin editor-btn__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" stroke-dasharray="60" stroke-dashoffset="15"/>
</svg>
<span class="editor-btn__label hidden sm:inline">{{ isSaving() ? 'Saving...' : 'Save' }}</span>
</button>
<!-- Wrap Toggle -->
<button
type="button"
class="editor-btn editor-btn--neutral"
(click)="toggleWordWrap()"
[attr.aria-label]="'Toggle word wrap'">
<svg class="editor-btn__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 7 4 4 20 4 20 7"/>
<line x1="9" y1="20" x2="15" y2="20"/>
<line x1="12" y1="4" x2="12" y2="20"/>
</svg>
<span class="editor-btn__label hidden md:inline">Wrap</span>
</button>
<!-- Undo -->
<button
type="button"
class="editor-btn editor-btn--ghost editor-btn--icon hidden sm:inline-flex"
(click)="undo()"
[attr.aria-label]="'Undo (Ctrl+Z)'">
<svg class="editor-btn__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
</svg>
</button>
<!-- Redo -->
<button
type="button"
class="editor-btn editor-btn--ghost editor-btn--icon hidden sm:inline-flex"
(click)="redo()"
[attr.aria-label]="'Redo (Ctrl+Y)'">
<svg class="editor-btn__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</button>
<!-- Close/Cancel -->
<button
type="button"
class="editor-btn editor-btn--neutral"
(click)="close()"
[attr.aria-label]="'Close editor (Esc)'">
<svg class="editor-btn__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
<span class="editor-btn__label hidden sm:inline">Close</span>
</button>
</div>
</div>
</header>
<!-- Editor Container -->
<div class="markdown-editor__container">
@ -152,137 +93,41 @@ import { EditorHighlightService } from '../../shared/editor/editor-highlight.ser
background: var(--bg-main);
}
.markdown-editor__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
background: var(--card);
gap: 0.75rem;
flex-wrap: wrap;
}
.markdown-editor__toolbar-left,
.markdown-editor__toolbar-right {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.editor-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.45rem 0.85rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
letter-spacing: 0.01em;
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
border: 1px solid transparent;
box-shadow: 0 6px 14px -12px color-mix(in oklab, var(--brand) 70%, transparent);
}
.editor-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px color-mix(in oklab, var(--brand) 35%, transparent);
}
.editor-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
box-shadow: none;
}
.editor-btn__icon {
width: 16px;
height: 16px;
}
.editor-btn__label {
white-space: nowrap;
}
.editor-btn--icon {
padding: 0.4rem;
border-radius: 9999px;
box-shadow: none;
}
.editor-btn--primary {
color: #0f172a;
background: linear-gradient(135deg,
color-mix(in oklab, var(--brand) 55%, transparent) 0%,
color-mix(in oklab, var(--brand-700) 45%, transparent) 100%);
border-color: color-mix(in oklab, var(--brand-700) 40%, transparent);
}
.editor-btn--primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 12px 22px -12px color-mix(in oklab, var(--brand) 80%, transparent);
}
.editor-btn--neutral {
color: inherit;
background: linear-gradient(135deg,
color-mix(in oklab, var(--surface-1, #e5e7eb) 65%, transparent) 0%,
color-mix(in oklab, var(--surface-2, #d1d5db) 55%, transparent) 100%);
border-color: color-mix(in oklab, var(--border, #cbd5e1) 55%, transparent);
box-shadow: none;
}
.editor-btn--neutral:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 8px 18px -14px color-mix(in oklab, var(--border, #cbd5e1) 65%, transparent);
}
.editor-btn--ghost {
color: var(--brand-500, var(--btn-bg));
background: transparent;
border-color: transparent;
box-shadow: none;
}
.editor-btn--ghost:hover:not(:disabled) {
background: color-mix(in srgb, var(--brand) 10%, transparent);
}
:host-context(.dark) .editor-btn--primary {
color: #e2e8f0;
background: linear-gradient(135deg,
color-mix(in oklab, var(--brand-700) 55%, transparent) 0%,
color-mix(in oklab, var(--brand-900, #1e1b4b) 55%, transparent) 100%);
border-color: color-mix(in oklab, var(--brand-700) 45%, transparent);
}
:host-context(.dark) .editor-btn--neutral {
color: #e2e8f0;
background: linear-gradient(135deg,
color-mix(in oklab, var(--card, #111827) 65%, transparent) 0%,
color-mix(in oklab, var(--surface2, #0b1220) 55%, transparent) 100%);
border-color: color-mix(in oklab, var(--border, #334155) 60%, transparent);
}
:host-context(.dark) .editor-btn--ghost {
color: color-mix(in oklab, var(--brand-200, #c7d2fe) 75%, #93c5fd 25%);
}
.editor-btn--ghost.editor-btn--icon:hover:not(:disabled) {
transform: translateY(-1px);
}
.markdown-editor__container {
flex: 1;
overflow: hidden;
position: relative;
padding: 1rem; /* Add some space around the editor */
box-sizing: border-box;
}
.markdown-editor__host {
height: 100%;
overflow: auto;
border-radius: 12px;
box-shadow: 0 0 20px rgba(0,0,0,0.2);
}
/* Custom Scrollbar for Webkit browsers */
.markdown-editor__host::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.markdown-editor__host::-webkit-scrollbar-track {
background: transparent;
}
.markdown-editor__host::-webkit-scrollbar-thumb {
background-color: rgba(100, 116, 139, 0.6);
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
.markdown-editor__host::-webkit-scrollbar-thumb:hover {
background-color: rgba(100, 116, 139, 0.8);
}
.markdown-editor__statusbar {
@ -309,7 +154,7 @@ import { EditorHighlightService } from '../../shared/editor/editor-highlight.ser
}
:host ::ng-deep .cm-content {
padding: 1rem;
padding: 1.5rem; /* p-6 */
}
/* Responsive */
@ -355,6 +200,7 @@ export class MarkdownEditorComponent implements OnInit, OnDestroy {
content = signal<string>('');
isDirty = signal<boolean>(false);
isSaving = signal<boolean>(false);
isAutoSaving = signal<boolean>(false);
wordWrap = signal<boolean>(false);
cursorLine = signal<number>(1);
cursorCol = signal<number>(1);
@ -362,6 +208,12 @@ export class MarkdownEditorComponent implements OnInit, OnDestroy {
isDarkTheme = signal<boolean>(false);
// Computed
noteColor = computed(() => {
const content = this.content();
const match = content.match(/---\s*[\s\S]*?color:\s*['"]?(#[0-9a-fA-F]{6}|[a-zA-Z]+)['"]?[\s\S]*?---/);
return match ? match[1] : null;
});
fileName = computed(() => {
const path = this.filePath();
return path ? path.split('/').pop() || '' : '';
@ -632,9 +484,11 @@ export class MarkdownEditorComponent implements OnInit, OnDestroy {
}
// Autosave after 5 seconds of inactivity
this.autosaveTimer = setTimeout(() => {
this.autosaveTimer = setTimeout(async () => {
if (this.isDirty() && !this.isSaving()) {
this.save();
this.isAutoSaving.set(true);
await this.save();
this.isAutoSaving.set(false);
}
}, 5000);
}

View File

@ -10,6 +10,8 @@ import { NoteContextMenuComponent } from '../../../components/note-context-menu/
import { WarningPanelComponent } from '../../components/warning-panel/warning-panel.component';
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';
@Component({
selector: 'app-notes-list',
@ -133,7 +135,7 @@ import { UrlStateService } from '../../services/url-state.service';
<ul class="notes-list">
<li *ngFor="let n of filtered()"
class="note-row cursor-pointer"
class="note-row note-card group cursor-pointer relative"
[class.active]="(selectedId() === n.id) || (pendingSelectId() === n.id)"
[ngClass]="getListItemClasses()"
[ngStyle]="getNoteGradientStyle(n)"
@ -142,24 +144,50 @@ import { UrlStateService } from '../../services/url-state.service';
tabindex="-1"
(click)="openNote.emit(n.id)"
(contextmenu)="openContextMenu($event, n)">
<!-- Action Buttons (hover reveal) -->
<div class="note-card-actions absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10">
<button
type="button"
class="action-btn edit inline-flex items-center justify-center w-7 h-7 rounded-lg bg-card/80 dark:bg-main/80 hover:bg-surface1 dark:hover:bg-surface2 transition-colors backdrop-blur-sm"
title="Éditer la note"
(click)="$event.stopPropagation(); editNote(n)">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button
type="button"
class="action-btn delete inline-flex items-center justify-center w-7 h-7 rounded-lg bg-card/80 dark:bg-main/80 hover:bg-red-100 dark:hover:bg-red-950 transition-colors backdrop-blur-sm"
title="Supprimer la note"
(click)="$event.stopPropagation(); openDeleteWarning(n)">
<svg class="w-4 h-4 text-red-600 dark:text-red-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
</button>
</div>
<!-- Compact View -->
<div *ngIf="state.viewMode() === 'compact'" class="note-inner">
<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>
<div class="title text-xs truncate">{{ n.title }}</div>
</div>
<!-- Comfortable View (default) -->
<div *ngIf="state.viewMode() === 'comfortable'" class="note-inner">
<div class="title text-sm truncate">{{ n.title }}</div>
<div class="meta text-xs truncate">{{ n.filePath }}</div>
<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>
<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>
</div>
</div>
<!-- Detailed View -->
<div *ngIf="state.viewMode() === 'detailed'" class="note-inner space-y-1.5">
<div class="title text-sm truncate">{{ n.title }}</div>
<div class="meta text-xs truncate">{{ n.filePath }}</div>
<div class="excerpt text-xs">
<span *ngIf="n.frontmatter?.status">Status: {{ n.frontmatter.status }}</span>
<span *ngIf="n.mtime" class="ml-2">{{ formatDate(n.mtime) }}</span>
<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>
<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>
<div class="excerpt text-xs">
<span *ngIf="n.frontmatter?.status">Status: {{ n.frontmatter.status }}</span>
<span *ngIf="n.mtime" class="ml-2">{{ formatDate(n.mtime) }}</span>
</div>
</div>
</div>
</li>
@ -362,6 +390,73 @@ import { UrlStateService } from '../../services/url-state.service';
color: var(--primary-foreground, #fafafa);
border-color: color-mix(in oklab, var(--primary) 35%, white 65%);
}
/* Enhanced note card with color indicator and action buttons */
.note-card {
/* Smooth hover lift effect */
transition: all 0.3s ease-in-out;
}
.note-card:hover {
transform: translateY(-1px);
}
/* Gradient background positioning */
.note-card {
background-repeat: no-repeat;
background-size: 100% 120px;
background-position: top center;
}
/* Color dot indicator */
.note-color-dot {
width: 8px;
height: 8px;
border-radius: 50%;
box-shadow: 0 0 0 1px color-mix(in oklab, var(--text-main) 15%, transparent 85%);
transition: all 0.2s ease-in-out;
}
.note-row:hover .note-color-dot {
box-shadow: 0 0 0 2px color-mix(in oklab, var(--text-main) 25%, transparent 75%);
}
/* Action buttons container */
.note-card-actions {
pointer-events: auto;
}
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0;
}
.action-btn.edit {
color: var(--primary, #3b82f6);
}
.action-btn.delete {
color: #dc2626;
}
:host-context(html.dark) .action-btn.delete {
color: #ef4444;
}
/* Touch devices: keep actions visible (no hover) */
@media (pointer: coarse) {
.note-card .note-card-actions {
opacity: 1 !important;
}
}
/* Ensure proper stacking context for action buttons */
.note-row {
overflow: visible;
}
`]
})
export class NotesListComponent {
@ -385,6 +480,8 @@ export class NotesListComponent {
@ViewChild('listContainer') listContainer?: ElementRef<HTMLElement>;
private urlState = inject(UrlStateService);
private pendingSelectId = signal<string | null>(null);
private editorState = inject(EditorStateService);
private vault = inject(VaultService);
// Delete warning modal state
deleteWarningOpen = signal<boolean>(false);
@ -743,4 +840,25 @@ export class NotesListComponent {
backgroundImage: `linear-gradient(to left, ${gradientColor} 0%, transparent 65%)`
} as Record<string, string>;
}
// Get note color for the indicator dot
getNoteColor(note: Note): string {
return note.frontmatter?.color || 'var(--text-muted)';
}
// Edit note action
editNote(note: Note): void {
try {
const full = this.vault.getNoteById(note.id);
if (full?.filePath) {
const content = (full as any).rawContent ?? full.content ?? '';
this.editorState.enterEditMode(full.filePath, content);
// also select the note in the list for visual sync
this.openNote.emit(note.id);
}
} catch (e) {
console.warn('[NotesList] Failed to enter edit mode for note:', note?.id, e);
this.openNote.emit(note.id);
}
}
}

View File

@ -0,0 +1,326 @@
import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy, HostListener, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { Note } from '../../../types';
import { VaultService } from '../../services/vault.service';
import { UrlStateService } from '../../services/url-state.service';
@Component({
selector: 'app-note-info-modal',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- Backdrop -->
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[60] flex items-center justify-center p-4 animate-fadeIn" (click)="onBackdrop()">
<!-- Panel -->
<div class="bg-card dark:bg-main border border-border dark:border-gray-700 rounded-2xl shadow-2xl shadow-[var(--shadow-glow,0_0_24px_var(--primary))] w-full max-w-4xl p-5 md:p-6 relative scale-100 animate-[fadeIn_120ms_ease,scaleIn_140ms_ease] flex flex-col max-h-[min(92vh,880px)] overflow-hidden" (click)="$event.stopPropagation()">
<!-- Decorative corner image/icon -->
<div class="absolute -top-3 -right-3 opacity-20 select-none pointer-events-none">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="text-muted"><path d="M4 19.5A2.5 2.5 0 0 0 6.5 22h11A2.5 2.5 0 0 0 20 19.5v-15A2.5 2.5 0 0 0 17.5 2h-11A2.5 2.5 0 0 0 4 4.5Z"/><path d="M8 7h8"/><path d="M8 11h8"/><path d="M8 15h6"/></svg>
</div>
<!-- Close button -->
<button type="button" class="absolute top-4 right-4 w-8 h-8 rounded-full hover:bg-surface1 dark:hover:bg-card transition-colors flex items-center justify-center text-muted hover:text-main" (click)="close.emit()" aria-label="Fermer">
<svg class="w-5 h-5" 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>
</button>
<div class="flex-1 overflow-y-auto pr-1">
<!-- Header -->
<div class="flex items-start gap-3 mb-6">
<div class="flex-shrink-0 w-10 h-10 rounded-xl bg-surface1 dark:bg-card flex items-center justify-center text-lg">
<span>🧾</span>
</div>
<div class="min-w-0">
<h2 class="text-xl md:text-2xl font-bold truncate">{{ note?.title }}</h2>
<div class="text-xs md:text-sm text-muted mt-1 break-all">{{ note?.filePath }}</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 pb-4">
<!-- A. Informations de base -->
<section class="rounded-2xl border border-border/80 dark:border-gray-700 bg-surface1/60 dark:bg-card/60 shadow-md p-5 md:p-6">
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2"><span>🧾</span> Informations de base</h3>
<div class="text-sm space-y-1">
<div><span class="text-muted">📄 Nom du fichier:</span> {{ base.fileName }}</div>
<div><span class="text-muted">📁 Dossier parent:</span> {{ base.parent }}</div>
<div><span class="text-muted">🧩 Type:</span> {{ base.type }}</div>
<div><span class="text-muted">💾 Taille:</span> {{ tech.sizeExact }}</div>
<div><span class="text-muted">🔡 Encodage:</span> UTF-8</div>
<div><span class="text-muted">🧠 MIME:</span> {{ tech.mime }}</div>
<div><span class="text-muted">🧭 Emplacement serveur:</span> /vault/{{ note?.filePath }}</div>
</div>
</section>
<!-- B. Métadonnées (Front-matter) -->
<section class="rounded-2xl border border-border/80 dark:border-gray-700 bg-surface1/60 dark:bg-card/60 shadow-md p-5 md:p-6">
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2"><span>🧠</span> Métadonnées</h3>
<div class="text-sm space-y-1">
<div><span class="text-muted">👤 Auteur:</span> {{ fm.auteur || note?.author || '—' }}</div>
<div><span class="text-muted">🕒 Création:</span> {{ note?.createdAt || '—' }}</div>
<div><span class="text-muted">🕒 Modification:</span> {{ note?.updatedAt || (note?.mtime ? (note?.mtime | date:'medium') : '—') }}</div>
<div><span class="text-muted">📜 Tags:</span> {{ (fm.tags || note?.tags || []).join(', ') || '—' }}</div>
<div><span class="text-muted">🗂 Catégorie:</span> {{ fm['catégorie'] || fm.categorie || '—' }}</div>
<div><span class="text-muted">🪪 Alias:</span> {{ (fm.aliases || []).join(', ') || '—' }}</div>
<div><span class="text-muted">🔖 Statut:</span> {{ fm.status || '—' }}</div>
<div><span class="text-muted"> Favoris:</span> {{ fm.favoris ? 'true' : 'false' }}</div>
<div><span class="text-muted">🔒 Lecture seule:</span> {{ fm.readOnly ? 'true' : 'false' }}</div>
<div *ngIf="hash" class="mt-2"><span class="text-muted">🔐 Hash (SHA-1):</span> {{ hash }}</div>
</div>
</section>
<!-- C. Statistiques de contenu -->
<section class="rounded-2xl border border-border/80 dark:border-gray-700 bg-surface1/60 dark:bg-card/60 shadow-md p-5 md:p-6">
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2"><span>📊</span> Statistiques de contenu</h3>
<div class="text-sm grid grid-cols-2 gap-x-3 gap-y-1">
<div><span class="text-muted">🔤 Caractères:</span> {{ stats.chars }}</div>
<div><span class="text-muted">📝 Mots:</span> {{ stats.words }}</div>
<div><span class="text-muted"> Phrases:</span> {{ stats.sentences }}</div>
<div><span class="text-muted">📑 Paragraphes:</span> {{ stats.paragraphs }}</div>
<div><span class="text-muted">📏 Longueur moyenne:</span> {{ stats.avgSentenceLen }}</div>
<div><span class="text-muted"># Titres H1H6:</span> {{ stats.headings }}</div>
<div><span class="text-muted"> Listes:</span> {{ stats.lists }}</div>
<div><span class="text-muted">🧩 Code:</span> {{ stats.codeBlocks }}</div>
<div><span class="text-muted">🖼 Images:</span> {{ stats.images }}</div>
<div><span class="text-muted">📊 Tableaux:</span> {{ stats.tables }}</div>
<div><span class="text-muted">💬 Citations:</span> {{ stats.quotes }}</div>
<div><span class="text-muted">🧠 Lisibilité (Flesch approx):</span> {{ stats.readability }}</div>
<div><span class="text-muted">🌍 Langue (naive):</span> {{ stats.lang }}</div>
<div><span class="text-muted">📌 Qualité contenu:</span> {{ stats.sizeClass }}</div>
</div>
</section>
<!-- D. Liens -->
<section class="rounded-2xl border border-border/80 dark:border-gray-700 bg-surface1/60 dark:bg-card/60 shadow-md p-5 md:p-6">
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2"><span>🔗</span> Liens</h3>
<div class="text-sm space-y-2">
<div class="flex items-center gap-3 flex-wrap">
<span class="text-muted">Internes ({{ links.internalCount }}):</span>
<ng-container *ngFor="let l of links.internalList; let i = index">
<button type="button" class="px-2 py-0.5 rounded-full text-xs bg-surface1 dark:bg-card hover:bg-surface2 transition border border-border/70" (click)="openInternalLink(l)">[[{{ l }}]]</button>
</ng-container>
</div>
<div class="space-y-1">
<div class="text-muted">Externes ({{ links.externalCount }}):</div>
<div class="flex flex-wrap gap-2">
<a *ngFor="let u of links.externalList" class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border border-border/70 hover:bg-surface1 dark:hover:bg-card transition" [href]="u" target="_blank" rel="noopener noreferrer">🔗 {{ toHost(u) }}</a>
</div>
</div>
<div><span class="text-muted">Cassés (internes):</span> {{ links.brokenInternal }}</div>
<div *ngIf="links.uniqueExternalDomains.length"><span class="text-muted">Domaines:</span> {{ links.uniqueExternalDomains.join(', ') }}</div>
</div>
</section>
</div>
</div>
<!-- Footer buttons -->
<div class="mt-6 flex flex-wrap gap-2 justify-between items-center">
<div class="flex gap-2">
<button type="button" class="inline-flex items-center gap-1.5 px-3 py-2 rounded bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-medium transition-colors" (click)="openFile()">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 13-5 5-5-5"/><path d="m18 6-5 5-5-5"/></svg>
Ouvrir le fichier
</button>
<button type="button" class="inline-flex items-center gap-1.5 px-3 py-2 rounded bg-surface1 dark:bg-card hover:bg-surface2 text-sm transition-colors" (click)="exportMetadata()">📤 Exporter les métadonnées</button>
<button type="button" class="inline-flex items-center gap-1.5 px-3 py-2 rounded bg-surface1 dark:bg-card hover:bg-surface2 text-sm transition-colors" (click)="copyAll()">📄 Copier toutes les infos</button>
</div>
<div>
<button type="button" class="inline-flex items-center gap-1.5 px-3 py-2 rounded bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-medium transition-colors" (click)="close.emit()">
<svg class="w-4 h-4" 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>
Fermer
</button>
</div>
</div>
</div>
</div>
`,
})
export class NoteInfoModalComponent {
@Input() note: Note | null = null;
@Output() close = new EventEmitter<void>();
private vault = inject(VaultService);
private urlState = inject(UrlStateService);
hash: string | null = null;
constructor() {
// Precompute hash asynchronously when opened
setTimeout(() => this.computeHash().catch(() => {}), 0);
}
get fm() { return this.note?.frontmatter || ({} as any); }
get base() {
const fileName = this.note?.fileName || '';
const parent = (this.note?.filePath || '').split('/').slice(0, -1).join('/') || '/';
const pathLower = (this.note?.filePath || '').toLowerCase();
const type = pathLower.endsWith('.excalidraw.md') ? 'Excalidraw' : (pathLower.endsWith('.json') ? 'JSON' : 'Markdown');
const sizeKb = Math.max(1, Math.round(((this.note?.content || '').length) / 1024));
return { fileName, parent, type, sizeKb };
}
get tech() {
const content = this.note?.content || '';
const bytes = new Blob([content]).size;
const sizeExact = this.formatBytes(bytes);
const mime = this.guessMime(this.note?.filePath || '');
return { sizeExact, mime };
}
get stats() {
const content = this.note?.content || '';
const chars = content.length;
const words = (content.trim().match(/\S+/g) || []).length;
const sentences = (content.match(/[\.!?]+\s|$/g) || []).length;
const paragraphs = content.split(/\n\s*\n/).filter(Boolean).length;
const headings = (content.match(/^#{1,6}\s+/gm) || []).length;
const lists = (content.match(/^(?:\s*[-*+]\s+|\s*\d+\.\s+)/gm) || []).length;
const codeBlocks = Math.floor(((content.match(/```/g) || []).length) / 2);
const images = (content.match(/!\[[^\]]*\]\([^\)]+\)/g) || []).length;
const tables = (content.match(/^\s*\|.*\|\s*$/gm) || []).length;
const quotes = (content.match(/^>\s+/gm) || []).length;
const avgSentenceLen = sentences ? Math.round((words / sentences) * 10) / 10 : 0;
const syllables = this.estimateSyllables(content);
const flesch = this.fleschReadingEase(words, sentences, syllables);
const readability = isFinite(flesch) ? Math.round(flesch) : 0;
const lang = this.detectLanguage(content);
const sizeClass = chars < 800 ? 'Note courte' : (chars < 2500 ? 'Note moyenne' : 'Note longue');
return { chars, words, sentences, paragraphs, headings, lists, codeBlocks, images, tables, quotes, avgSentenceLen, readability, lang, sizeClass };
}
get links() {
const content = this.note?.content || '';
const internal = Array.from(content.matchAll(/\[\[([^\]]+)\]\]/g)).map(m => m[1]);
const external = Array.from(content.matchAll(/https?:\/\/[^\s)]+/g)).map(m => m[0]);
const allNotes = this.vault.allNotes() || [];
const paths = new Set(allNotes.map(n => (n.filePath || '').toLowerCase()));
const brokenInternal = internal.filter(link => {
// Try to resolve to a markdown path: allow [[name]] or [[path/name]] (without .md)
const normalized = (link || '').replace(/\\/g, '/').replace(/\s/g, '%20');
// Check with and without .md
const withMd = (normalized.endsWith('.md') ? normalized : normalized + '.md').toLowerCase();
return !paths.has(withMd);
}).length;
const uniqueExternalDomains = Array.from(new Set(external.map(url => {
try { return new URL(url).host; } catch { return null; }
}).filter(Boolean) as string[]));
return { internalCount: internal.length, externalCount: external.length, brokenInternal, uniqueExternalDomains, internalList: internal, externalList: external };
}
@HostListener('document:keydown.escape') onEsc() { this.close.emit(); }
onBackdrop() { this.close.emit(); }
openFile() {
const path = this.note?.filePath;
if (!path) return;
try { this.urlState.openNote(path); } catch {}
this.close.emit();
}
exportMetadata() {
const payload = {
title: this.note?.title,
path: this.note?.filePath,
base: this.base,
frontmatter: this.fm,
stats: this.stats,
links: this.links,
tech: this.tech,
hash: this.hash
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${(this.note?.fileName || 'note')}.info.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async copyAll() {
try {
const text = [
`Titre: ${this.note?.title}`,
`Chemin: ${this.note?.filePath}`,
`Nom fichier: ${this.base.fileName}`,
`Dossier: ${this.base.parent}`,
`Type: ${this.base.type}`,
`Taille: ${this.tech.sizeExact}`,
`MIME: ${this.tech.mime}`,
`Hash: ${this.hash ?? ''}`,
`Tags: ${(this.fm.tags || this.note?.tags || []).join(', ')}`,
].join('\n');
await navigator.clipboard.writeText(text);
} catch {}
}
openInternalLink(label: string) {
const normalized = (label || '').replace(/\\/g, '/');
const target = normalized.endsWith('.md') ? normalized : `${normalized}.md`;
const all = this.vault.allNotes() || [];
const found = all.find(n => (n.filePath || '').toLowerCase() === target.toLowerCase());
if (found?.filePath) {
try { this.urlState.openNote(found.filePath); } catch {}
this.close.emit();
}
}
toHost(url: string): string {
try { return new URL(url).host; } catch { return url; }
}
private guessMime(p: string): string {
const low = p.toLowerCase();
if (low.endsWith('.md')) return 'text/markdown';
if (low.endsWith('.json')) return 'application/json';
if (low.endsWith('.excalidraw') || low.endsWith('.excalidraw.md')) return 'application/vnd.excalidraw+json';
return 'text/plain';
}
private formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
}
private async computeHash() {
try {
const content = this.note?.content || '';
const enc = new TextEncoder();
const data = enc.encode(content);
const digest = await crypto.subtle.digest('SHA-1', data);
this.hash = Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
} catch {
this.hash = null;
}
}
private estimateSyllables(text: string): number {
const vowels = text.match(/[aeiouyàâäéèêëîïôöùûü]/gi) || [];
return Math.max(1, Math.round(vowels.length / 3));
}
private fleschReadingEase(words: number, sentences: number, syllables: number): number {
if (!words || !sentences || !syllables) return 0;
// Flesch Reading Ease (approx)
const ASL = words / sentences; // average sentence length
const ASW = syllables / words; // average syllables per word
return 206.835 - (1.015 * ASL) - (84.6 * ASW);
}
private detectLanguage(text: string): string {
const hasFrenchChars = /[àâäçéèêëîïôöùûüÿœ]/i.test(text);
const hasEnglishWords = /\b(the|and|of|to|in|is|that|it|for|on)\b/i.test(text);
if (hasFrenchChars) return 'fr (naif)';
if (hasEnglishWords) return 'en (naive)';
return '—';
}
}

View File

@ -0,0 +1,196 @@
:host {
position: relative;
display: inline-block;
font-family: var(--font-ui);
}
.move-menu-trigger {
padding: 0.3rem 0.5rem;
border-radius: 0.75rem;
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
background: color-mix(in srgb, var(--surface-1) 60%, transparent);
color: var(--text-muted);
transition: background var(--transition-base), border-color var(--transition-base), color var(--transition-base), box-shadow var(--transition-base);
}
.move-menu-trigger:hover,
.move-menu-trigger:focus-visible {
color: var(--text-main);
border-color: color-mix(in srgb, var(--border) 90%, transparent);
background: color-mix(in srgb, var(--surface-2) 80%, transparent);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.18);
}
.move-menu-trigger:focus-visible {
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 30%, transparent);
}
.move-menu-panel {
/* Solid, non-transparent panel aligned with site menus */
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 1rem;
box-shadow:
0 16px 42px var(--shadow-color),
0 4px 10px color-mix(in srgb, var(--shadow-color) 55%, transparent);
overflow: hidden;
backdrop-filter: none;
opacity: 1;
isolation: isolate;
}
.move-menu-header {
padding: 1rem 1.1rem 0.7rem;
border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
}
.move-menu-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--text-muted);
}
.move-menu-link {
font-size: 0.75rem;
font-weight: 500;
color: var(--link);
text-decoration: none;
transition: color var(--transition-base);
}
.move-menu-link:hover {
color: var(--link-hover);
text-decoration: underline;
}
.move-menu-body {
padding: 0.9rem 1.1rem 1.1rem;
}
.move-menu-search {
padding: 0.6rem 0.75rem;
border-radius: 0.9rem;
background: var(--surface-2);
border: 1px solid var(--border);
}
.move-menu-input {
background: transparent;
border: none;
outline: none;
color: var(--text-main);
}
.move-menu-input::placeholder {
color: color-mix(in srgb, var(--text-muted) 70%, transparent);
}
.move-menu-clear {
padding: 0.2rem 0.45rem;
border-radius: 0.5rem;
border: none;
background: color-mix(in srgb, var(--surface-1) 80%, transparent);
color: var(--text-muted);
cursor: pointer;
transition: background var(--transition-base), color var(--transition-base);
}
.move-menu-clear:hover {
background: var(--surface-2);
color: var(--text-main);
}
.move-menu-empty {
font-size: 0.75rem;
color: color-mix(in srgb, var(--text-muted) 85%, transparent);
padding: 0.25rem 0.1rem;
}
.move-menu-section-title {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.22em;
text-transform: uppercase;
color: color-mix(in srgb, var(--text-muted) 75%, transparent);
}
.move-menu-breadcrumb {
font-size: 0.78rem;
color: var(--text-muted);
}
.move-menu-crumb {
border: none;
background: none;
color: inherit;
padding: 0.15rem 0.25rem;
border-radius: 0.45rem;
cursor: pointer;
transition: color var(--transition-base), background var(--transition-base);
}
.move-menu-crumb:hover {
color: var(--text-main);
background: var(--surface-1);
}
.move-menu-crumb-separator {
opacity: 0.5;
}
.move-menu-list,
.move-menu-search-results {
max-height: 14rem;
border-radius: 0.9rem;
overflow: auto;
padding: 0.25rem 0;
}
.move-folder-button,
.move-search-result {
width: 100%;
text-align: left;
border: none;
background: none;
border-radius: 0.9rem;
padding: 0.65rem 0.75rem;
color: var(--text-main);
transition: background var(--transition-base), color var(--transition-base), transform var(--transition-base);
}
.move-folder-button:hover,
.move-search-result:hover {
background: var(--surface-2);
transform: translateX(2px);
}
.move-folder-expand {
border: none;
background: none;
font-size: 0.75rem;
color: var(--text-muted);
cursor: pointer;
transition: color var(--transition-base);
}
.move-folder-expand:hover {
color: var(--text-main);
}
.move-menu-footer {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
@media (max-width: 540px) {
.move-menu-panel {
width: calc(100vw - 2rem);
left: 1rem !important;
right: 1rem;
}
}

View File

@ -2,7 +2,7 @@
<button
#trigger
type="button"
class="inline-flex items-center gap-1 text-sm text-muted hover:text-main transition-color"
class="move-menu-trigger inline-flex items-center gap-1 text-sm text-muted hover:text-main transition-color"
(click)="toggleMenu()"
>
<span class="truncate" [title]="currentPath">
@ -12,15 +12,15 @@
</button>
@if (showMenu()) {
<div class="fixed z-50 w-80 rounded-xl border border-border bg-card shadow-lg"
<div class="fixed z-[1000] w-80 move-menu-panel"
[style.top.px]="menuTop()" [style.left.px]="menuLeft()">
<div class="flex items-center justify-between px-3 py-2 border-b border-border/60">
<div class="text-xs uppercase tracking-wide text-muted">Move note to folder</div>
<button class="text-xs text-accent hover:underline" (click)="openFolderInSidebar()">Open sidebar</button>
<div class="move-menu-header flex items-center justify-between">
<div class="move-menu-title">Move note to folder</div>
<button class="move-menu-link" (click)="openFolderInSidebar()">Open sidebar</button>
</div>
<div class="p-3 space-y-3">
<div class="flex items-center gap-2 bg-surface1 rounded-lg px-2 py-1.5">
<div class="move-menu-body space-y-3">
<div class="move-menu-search flex items-center gap-2">
<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-muted">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
@ -28,38 +28,38 @@
<input
#searchField
type="text"
class="bg-transparent text-sm flex-1 outline-none"
class="move-menu-input text-sm flex-1"
placeholder="Search folders"
[value]="searchQuery()"
(input)="onSearchChange($any($event.target).value)"
/>
@if (searchQuery()) {
<button type="button" class="text-muted hover:text-main" (click)="clearSearch()"></button>
<button type="button" class="move-menu-clear" (click)="clearSearch()"></button>
}
</div>
@if (isSearching() && searchResults().length === 0) {
<div class="text-xs text-muted px-1">No matching folders</div>
<div class="move-menu-empty">No matching folders</div>
}
@if (!isSearching()) {
<div class="text-xs text-muted px-1">All my folders</div>
<div class="flex items-center gap-1 text-xs text-muted/80">
<button class="hover:text-main" (click)="goToRoot()">All my folders</button>
<div class="move-menu-section-title">All my folders</div>
<div class="move-menu-breadcrumb flex items-center gap-1">
<button class="move-menu-crumb" (click)="goToRoot()">All my folders</button>
@for (crumb of breadcrumb(); track crumb.path; let i = $index) {
<span></span>
<button class="hover:text-main" (click)="navigateTo(i + 1)">{{ crumb.name }}</button>
<span class="move-menu-crumb-separator"></span>
<button class="move-menu-crumb" (click)="navigateTo(i + 1)">{{ crumb.name }}</button>
}
</div>
<div class="max-h-64 overflow-y-auto space-y-1">
<div class="move-menu-list">
@if (loading()) {
<div class="text-xs text-muted px-2 py-3">Loading folders…</div>
<div class="move-menu-empty">Loading folders…</div>
} @else {
@for (folder of currentLevelFolders(); track folder.path) {
<button
type="button"
class="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-sm hover:bg-surface1"
class="move-folder-button"
(click)="onFolderSelected(folder, $event)"
>
<span class="flex items-center gap-2">
@ -69,7 +69,7 @@
<span class="truncate">{{ folder.name }}</span>
</span>
@if (folder.children?.length) {
<button type="button" class="text-xs text-muted hover:text-main" (click)="navigateInto(folder, $event)">
<button type="button" class="move-folder-expand" (click)="navigateInto(folder, $event)">
▶ {{ folder.children.length }} subfolder{{ folder.children.length > 1 ? 's' : '' }}
</button>
}
@ -80,11 +80,11 @@
}
@if (isSearching()) {
<div class="max-h-64 overflow-y-auto space-y-1">
<div class="move-menu-search-results">
@for (result of searchResults(); track result.path) {
<button
type="button"
class="w-full flex flex-col items-start px-2 py-1.5 rounded-lg text-sm hover:bg-surface1 text-left"
class="move-search-result"
(click)="onSearchResultSelected(result)"
>
<span class="font-medium">{{ result.name }}</span>
@ -94,9 +94,9 @@
</div>
}
<div class="flex items-center justify-between pt-2 border-t border-border/60">
<button class="text-xs text-muted hover:text-main" (click)="moveToCurrentFolder()">Move here</button>
<button class="text-xs text-muted hover:text-main" (click)="closeMenu()">Cancel</button>
<div class="move-menu-footer flex items-center justify-between">
<button class="btn btn-standard-primary btn-standard-sm" (click)="moveToCurrentFolder()">Move here</button>
<button class="btn btn-standard-sm" (click)="closeMenu()">Cancel</button>
</div>
</div>
</div>

View File

@ -33,7 +33,8 @@ interface FlattenedFolder {
selector: 'app-move-note-to-folder',
standalone: true,
imports: [CommonModule],
templateUrl: './move-note-to-folder.component.html'
templateUrl: './move-note-to-folder.component.html',
styleUrls: ['./move-note-to-folder.component.css']
})
export class MoveNoteToFolderComponent {
@Input()
@ -322,11 +323,13 @@ export class MoveNoteToFolderComponent {
const t = this.trigger?.nativeElement;
if (!t) return;
const rect = t.getBoundingClientRect();
// Place menu just under the trigger, with small margin
this.menuTop.set(Math.round(rect.bottom + window.scrollY + 8));
// Try to align left edges; ensure not off-screen (simple clamp)
const left = Math.round(rect.left + window.scrollX);
this.menuLeft.set(Math.max(8, left));
// Place menu just under the trigger (fixed panel -> viewport coords)
this.menuTop.set(Math.round(rect.bottom + 8));
// Align left edge; clamp to viewport
const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
const desiredLeft = Math.round(rect.left);
const clampedLeft = Math.min(Math.max(8, desiredLeft), vw - 8 - 320); // ~w-80
this.menuLeft.set(clampedLeft);
}
private attachGlobalListeners(): void {

View File

@ -2,6 +2,28 @@
width: 100%;
min-width: 0;
overflow: visible;
position: relative;
}
/* Full-width top gradient background layer */
.note-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 120px; /* covers the whole top area visually */
background-image: var(--note-accent-gradient, none);
background-repeat: no-repeat;
background-size: 100% 100%;
pointer-events: none;
z-index: 0;
}
/* Ensure content renders above the gradient layer */
.note-header > * {
position: relative;
z-index: 1;
}
.path-wrap {

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, HostListener, Input, Output, inject, effect } from '@angular/core';
import { Component, EventEmitter, HostListener, Input, Output, inject, effect, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UiModeService } from '../../shared/services/ui-mode.service';
import { ResponsiveService } from '../../shared/services/responsive.service';
@ -20,13 +20,17 @@ import { TestsPanelComponent } from '../../features/tests/tests-panel.component'
import { ParametersPage } from '../../features/parameters/parameters.page';
import { AboutPanelComponent } from '../../features/about/about-panel.component';
import { UrlStateService } from '../../services/url-state.service';
import { NoteInfoModalComponent } from '../../features/note-info/note-info-modal.component';
import { NoteInfoModalService } from '../../services/note-info-modal.service';
import { InPageSearchService } from '../../shared/search/in-page-search.service';
import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search-overlay.component';
@Component({
selector: 'app-shell-nimbus-layout',
standalone: true,
imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, NotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent, TestsPanelComponent, ParametersPage, AboutPanelComponent],
imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, NotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent, TestsPanelComponent, ParametersPage, AboutPanelComponent, NoteInfoModalComponent, InPageSearchOverlayComponent],
template: `
<div class="relative h-screen flex flex-col bg-card dark:bg-main text-main dark:text-gray-100">
<div class="relative h-screen flex flex-col bg-card dark:bg-main text-main dark:text-gray-100" [style.--sidebar-width.px]="isSidebarOpen ? leftSidebarWidth : 64">
<!-- Fullscreen overlay for note -->
<div *ngIf="noteFullScreen && selectedNote && activeView !== 'markdown-playground'" class="absolute inset-0 z-50 flex flex-col bg-card dark:bg-main">
@ -42,10 +46,12 @@ import { UrlStateService } from '../../services/url-state.service';
(fullScreenRequested)="toggleNoteFullScreen()"
(legacyRequested)="ui.toggleUIMode()"
(parametersRequested)="onParametersOpen()"
(searchRequested)="openInPageSearch()"
(showToc)="toggleOutlineRequest.emit()"
(directoryClicked)="onFolderSelected($event)"
[tocOpen]="isOutlineOpen"
></app-note-viewer>
<app-in-page-search-overlay></app-in-page-search-overlay>
</div>
</div>
@ -148,26 +154,6 @@ import { UrlStateService } from '../../services/url-state.service';
</ng-container>
</div>
</div>
<!-- Tests section (visible when sidebar is open) -->
<div class="rounded-2xl border border-border/70 bg-card/85 px-3 py-3 shadow-subtle">
<div class="grid grid-cols-3 gap-2 sm:grid-cols-5">
<div class="text-xs font-semibold uppercase tracking-wide text-text-muted mb-1">Tests</div>
<div class="space-y-1">
<button
type="button"
class="w-full text-left block text-sm px-2 py-1.5 rounded hover:bg-surface1 dark:hover:bg-card text-main dark:text-main"
(click)="onMarkdownPlaygroundSelected()">
📝 Markdown Playground
</button>
<button
type="button"
class="w-full text-left block text-sm px-2 py-1.5 rounded hover:bg-surface1 dark:hover:bg-card text-main dark:text-main"
(click)="onTestsPanelSelected()">
🔬 API Tests Panel
</button>
</div>
</div>
</div>
</ng-template>
<!-- Left Resizer -->
@ -197,7 +183,7 @@ import { UrlStateService } from '../../services/url-state.service';
<!-- Note View + ToC -->
<section class="flex-1 relative min-w-0 flex">
<div 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 px-4 py-4 lg:px-8" 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>
@ -208,14 +194,14 @@ import { UrlStateService } from '../../services/url-state.service';
(noteLinkClicked)="noteSelected.emit($event)"
(tagClicked)="onTagSelected($event)"
(wikiLinkActivated)="wikiLinkActivated.emit($event)"
[fullScreenActive]="noteFullScreen"
(searchRequested)="openInPageSearch()"
(fullScreenRequested)="toggleNoteFullScreen()"
(legacyRequested)="ui.toggleUIMode()"
(parametersRequested)="onParametersOpen()"
(showToc)="toggleOutlineRequest.emit()"
(directoryClicked)="onFolderSelected($event)"
[tocOpen]="isOutlineOpen"
></app-note-viewer>
<app-in-page-search-overlay></app-in-page-search-overlay>
</div>
<aside class="hidden xl:block border-l border-border dark:border-gray-800 overflow-y-auto transition-all duration-300 ease-in-out" appScrollableOverlay [style.width.px]="isOutlineOpen ? rightSidebarWidth : 0" [class.opacity-0]="!isOutlineOpen" [class.pointer-events-none]="!isOutlineOpen">
<div class="p-3 space-y-3">
@ -314,7 +300,7 @@ import { UrlStateService } from '../../services/url-state.service';
} @else {
<div class="note-content-area h-full px-3 py-3 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)" (fullScreenRequested)="toggleNoteFullScreen()" (parametersRequested)="onParametersOpen()"></app-note-viewer>
<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 {
<div class="mt-10 text-center text-sm text-muted dark:text-muted">
<div class="text-4xl mb-3">📄</div>
@ -332,15 +318,20 @@ import { UrlStateService } from '../../services/url-state.service';
<!-- About Panel Overlay -->
<app-about-panel *ngIf="showAboutPanel" (close)="showAboutPanel = false"></app-about-panel>
<!-- Note Info Modal -->
<app-note-info-modal *ngIf="noteInfo.visible()" [note]="noteInfo.note()!" (close)="noteInfo.close()"></app-note-info-modal>
</div>
`
})
export class AppShellNimbusLayoutComponent {
export class AppShellNimbusLayoutComponent implements AfterViewInit {
ui = inject(UiModeService);
vault = inject(VaultService);
responsive = inject(ResponsiveService);
mobileNav = inject(MobileNavService);
urlState = inject(UrlStateService);
noteInfo = inject(NoteInfoModalService);
inPageSearch = inject(InPageSearchService);
noteFullScreen = false;
showAboutPanel = false;
@ -360,6 +351,8 @@ export class AppShellNimbusLayoutComponent {
@Input() tags: TagInfo[] = [];
@Input() activeView: string = 'files';
@ViewChild('pageRoot', { static: false }) pageRoot?: ElementRef<HTMLElement>;
@Output() noteSelected = new EventEmitter<string>();
@Output() tagClicked = new EventEmitter<string>();
@Output() wikiLinkActivated = new EventEmitter<any>();
@ -596,6 +589,30 @@ export class AppShellNimbusLayoutComponent {
return [...list].sort((a, b) => (score(b) - score(a)));
}
ngAfterViewInit(): void {
queueMicrotask(() => this.inPageSearch.setRoot(this.pageRoot?.nativeElement || null));
}
@HostListener('document:keydown', ['$event'])
onKeydown(e: KeyboardEvent) {
const isFind = (e.ctrlKey || e.metaKey) && (e.key === 'f' || e.key === 'F');
if (isFind) {
e.preventDefault();
this.openInPageSearch();
} else if (e.key === 'Escape' && this.inPageSearch.openState()) {
this.inPageSearch.close();
} else if (e.key === 'Enter' && this.inPageSearch.openState()) {
if (e.shiftKey) this.inPageSearch.prev(); else this.inPageSearch.next();
e.preventDefault();
}
}
openInPageSearch(): void {
this.inPageSearch.open();
document.dispatchEvent(new CustomEvent('ov-search-focus'));
this.inPageSearch.setRoot(this.pageRoot?.nativeElement || null);
}
onQueryChange(query: string) {
this.listQuery = query;
this.autoSelectFirstNote();
@ -637,6 +654,10 @@ export class AppShellNimbusLayoutComponent {
}
}
onAboutSelected(): void {
this.showAboutPanel = true;
}
onNoteCreated(noteId: string) {
this.noteCreated.emit(noteId);
}

View File

@ -3,12 +3,14 @@ import type { Note } from '../../types';
import { ToastService } from '../shared/toast/toast.service';
import { VaultService } from './vault.service';
import { UrlStateService } from './url-state.service';
import { NoteInfoModalService } from './note-info-modal.service';
@Injectable({ providedIn: 'root' })
export class NoteContextMenuService {
private readonly toast = inject(ToastService);
private readonly vaultService = inject(VaultService);
private readonly urlState = inject(UrlStateService);
private readonly noteInfo = inject(NoteInfoModalService);
// État du menu
readonly visible = signal(false);
@ -176,31 +178,13 @@ export class NoteContextMenuService {
}
showPageInfo(note: Note): void {
// Calculer les statistiques
const wordCount = note.content.split(/\s+/).length;
const headingCount = (note.content.match(/^#+\s/gm) || []).length;
const backlinksCount = note.backlinks?.length || 0;
const info = `
📄 Informations de la page
📁 Chemin: ${note.filePath}
📝 Titre: ${note.title}
📊 Mots: ${wordCount}
🏷 Titres: ${headingCount}
🔗 Liens entrants: ${backlinksCount}
📅 Créée: ${note.createdAt || 'Inconnue'}
🔄 Modifiée: ${note.updatedAt || new Date(note.mtime).toLocaleDateString('fr-FR')}
🏷 Tags: ${note.tags?.join(', ') || 'Aucun'}
${note.frontmatter?.color ? `🎨 Couleur: ${note.frontmatter.color}` : ''}
${note.frontmatter?.readOnly ? '🔒 Lecture seule' : '✏️ Modifiable'}
${note.frontmatter?.favoris ? '⭐ Favori' : ''}
`.trim();
console.log(info);
this.toast.info('Informations affichées dans la console');
this.emitEvent('noteInfoRequested', { path: note.filePath, info });
try {
this.noteInfo.open(note);
this.emitEvent('noteInfoRequested', { path: note.filePath });
} catch (error) {
console.error('Open note info modal error:', error);
this.toast.info('Impossible d\'ouvrir la fenêtre d\'information');
}
}
async toggleReadOnly(note: Note): Promise<void> {

View File

@ -0,0 +1,18 @@
import { Injectable, signal } from '@angular/core';
import type { Note } from '../../types';
@Injectable({ providedIn: 'root' })
export class NoteInfoModalService {
readonly visible = signal(false);
readonly note = signal<Note | null>(null);
open(n: Note): void {
this.note.set(n);
this.visible.set(true);
}
close(): void {
this.visible.set(false);
this.note.set(null);
}
}

View File

@ -0,0 +1,78 @@
import { Component, ChangeDetectionStrategy, effect, inject, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { InPageSearchService } from './in-page-search.service';
@Component({
selector: 'app-in-page-search-overlay',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section *ngIf="search.openState()" class="fixed bottom-3 left-1/2 -translate-x-1/2 z-[1000] w-[min(720px,calc(100vw-24px))]">
<div class="rounded-2xl border border-border bg-card shadow-lg px-3 py-2 flex flex-col gap-2">
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35" />
</svg>
<input
#q
type="search"
[value]="search.query()"
(input)="onQuery(($any($event.target).value || '').toString())"
placeholder="Rechercher dans la note…"
class="flex-1 bg-transparent text-sm text-main placeholder:text-muted focus:outline-none"
/>
<button
type="button"
class="rounded-lg px-2 py-1 text-xs font-medium text-muted hover:bg-surface1 dark:hover:bg-card transition"
(click)="close()"
aria-label="Fermer la recherche"
>
Fermer
</button>
</div>
<div class="flex items-center justify-between text-xs text-muted">
<span class="font-medium">{{ positionLabel() }}</span>
<div class="flex items-center gap-1">
<button type="button" class="inline-flex items-center gap-1 rounded-lg px-2 py-1 hover:bg-surface1 dark:hover:bg-card transition" (click)="prev()" aria-label="Occurrence précédente">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
<span>Préc.</span>
</button>
<button type="button" class="inline-flex items-center gap-1 rounded-lg px-2 py-1 hover:bg-surface1 dark:hover:bg-card transition" (click)="next()" aria-label="Occurrence suivante">
<span>Suiv.</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6" /></svg>
</button>
</div>
</div>
</div>
</section>
`
})
export class InPageSearchOverlayComponent {
search = inject(InPageSearchService);
positionLabel = computed(() => {
const total = this.search.count();
if (!total) return '0 occurrence';
const current = this.search.currentIndex() + 1;
return `${current}/${total} occurrence${total > 1 ? 's' : ''}`;
});
constructor() {
effect(() => {
if (!this.search.openState()) return;
queueMicrotask(() => {
const el = document.querySelector<HTMLInputElement>('app-in-page-search-overlay input[type="search"]');
el?.focus();
});
});
}
onQuery(q: string) {
this.search.queryChange(q);
}
next() { this.search.next(); }
prev() { this.search.prev(); }
close() { this.search.close(); }
}

View File

@ -0,0 +1,122 @@
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class InPageSearchService {
private rootEl: HTMLElement | null = null;
private marks: HTMLElement[] = [];
private activeIndex = signal<number>(-1);
count = signal<number>(0);
query = signal<string>('');
openState = signal<boolean>(false);
setRoot(el: HTMLElement | null) {
this.rootEl = el;
}
open() {
this.openState.set(true);
}
close() {
this.clear();
this.openState.set(false);
}
queryChange(q: string) {
this.query.set(q);
this.highlight(q);
}
next() {
if (!this.marks.length) return;
const next = (this.activeIndex() + 1) % this.marks.length;
this.setActive(next);
}
prev() {
if (!this.marks.length) return;
const prev = (this.activeIndex() - 1 + this.marks.length) % this.marks.length;
this.setActive(prev);
}
clear() {
// remove active class first
this.marks.forEach(m => m.classList.remove('ov-mark-active'));
// unwrap marks
for (const m of this.marks) {
const parent = m.parentNode as Node | null;
if (!parent) continue;
const text = document.createTextNode(m.textContent || '');
parent.replaceChild(text, m);
(parent as any).normalize?.();
}
this.marks = [];
this.count.set(0);
this.activeIndex.set(-1);
}
currentIndex(): number {
return this.activeIndex();
}
private highlight(term: string) {
this.clear();
if (!term || !this.rootEl) return;
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(escaped, 'gi');
const walker = document.createTreeWalker(this.rootEl, NodeFilter.SHOW_TEXT, {
acceptNode: (node: any) => {
const parentEl = (node.parentElement as HTMLElement) || null;
if (!parentEl) return NodeFilter.FILTER_REJECT;
const tag = parentEl.tagName.toLowerCase();
if (tag === 'script' || tag === 'style' || tag === 'mark') return NodeFilter.FILTER_REJECT;
if (parentEl.closest('code, pre, button, a')) return NodeFilter.FILTER_REJECT;
const val = node.nodeValue || '';
if (!val.trim()) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
} as any);
const nodes: Text[] = [];
while (walker.nextNode()) nodes.push(walker.currentNode as Text);
let count = 0;
for (const textNode of nodes) {
const text = textNode.nodeValue || '';
pattern.lastIndex = 0;
let m: RegExpExecArray | null;
let last = 0;
const fragments: (string | HTMLElement)[] = [];
while ((m = pattern.exec(text)) && count < 2000) {
if (m.index > last) fragments.push(text.slice(last, m.index));
const mark = document.createElement('mark');
mark.className = 'ov-mark';
mark.textContent = m[0];
fragments.push(mark);
last = m.index + m[0].length;
count++;
}
if (!fragments.length) continue;
if (last < text.length) fragments.push(text.slice(last));
const parent = textNode.parentNode as Node | null;
if (!parent) continue;
const frag = document.createDocumentFragment();
for (const f of fragments) frag.appendChild(typeof f === 'string' ? document.createTextNode(f) : f);
parent.replaceChild(frag, textNode);
}
this.marks = Array.from(this.rootEl.querySelectorAll('mark.ov-mark')) as HTMLElement[];
this.count.set(this.marks.length);
if (this.marks.length) this.setActive(0);
}
private setActive(idx: number) {
if (!this.marks.length) return;
const bounded = Math.max(0, Math.min(idx, this.marks.length - 1));
this.marks.forEach(m => m.classList.remove('ov-mark-active'));
const el = this.marks[bounded];
el.classList.add('ov-mark-active');
try { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch {}
this.activeIndex.set(bounded);
}
}

View File

@ -20,6 +20,22 @@
transition: transform 0.15s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease;
}
/* Disabled state for alphabet buttons */
.btn-disabled,
.btn-colored-xs:disabled {
opacity: 0.55;
filter: grayscale(0.4) saturate(0.7);
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-colored-xs:disabled:hover,
.btn-colored-xs:disabled:focus-visible {
transform: none;
box-shadow: none;
}
.btn-colored-xs:hover,
.btn-colored-xs:focus-visible {
transform: translateY(-1px);
@ -64,3 +80,80 @@
color-mix(in oklab, var(--surface2, #0b1220) 60%, transparent) 100%);
border-color: color-mix(in oklab, var(--border, #334155) 60%, transparent);
}
/* Ensure the overlay panel is fully opaque and above editors */
.tag-overlay-panel {
background-color: var(--card, #0b1220);
-webkit-backdrop-filter: none;
backdrop-filter: none;
position: relative;
z-index: 1; /* within the overlay root (zIndex set inline) */
}
:host-context(:not(.dark)) .tag-overlay-panel {
background-color: var(--card, #ffffff);
}
/* Tag badge styling (copied locally so overlay keeps colors/icons when moved under <body>) */
.md-tag-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.1rem;
padding: 0.25rem 0.55rem;
border-radius: 9999px;
border: none;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.03em;
color: #0f172a;
background: rgba(15, 118, 110, 0.12);
cursor: default;
transition: background 0.15s ease, transform 0.15s ease;
gap: 0.25rem;
}
.md-tag-badge::before {
content: '\1F516'; /* 🔖 */
font-size: 0.9em;
opacity: 0.8;
}
:host-context(.dark) .md-tag-badge {
background: rgba(45, 212, 191, 0.22);
color: #e2e8f0;
}
.md-tag-badge button {
font-size: 0.85em;
line-height: 1;
padding: 0;
background: transparent;
color: inherit;
}
.md-tag-color-0 { background: rgba(190, 242, 100, 0.35); color: #3f6212; }
.md-tag-color-1 { background: rgba(129, 230, 217, 0.35); color: #0f766e; }
.md-tag-color-2 { background: rgba(196, 181, 253, 0.35); color: #6b21a8; }
.md-tag-color-3 { background: rgba(248, 196, 113, 0.35); color: #b45309; }
.md-tag-color-4 { background: rgba(248, 113, 113, 0.35); color: #b91c1c; }
.md-tag-color-5 { background: rgba(129, 140, 248, 0.35); color: #4338ca; }
.md-tag-color-6 { background: rgba(233, 213, 255, 0.35); color: #7e22ce; }
.md-tag-color-7 { background: rgba(209, 250, 229, 0.35); color: #047857; }
.md-tag-color-8 { background: rgba(165, 180, 252, 0.35); color: #1d4ed8; }
.md-tag-color-9 { background: rgba(253, 224, 71, 0.35); color: #92400e; }
.md-tag-color-10 { background: rgba(244, 114, 182, 0.35); color: #be185d; }
.md-tag-color-11 { background: rgba(148, 163, 184, 0.35); color: #1f2937; }
:host-context(.dark) .md-tag-color-0 { background: rgba(132, 204, 22, 0.35); color: #d9f99d; }
:host-context(.dark) .md-tag-color-1 { background: rgba(45, 212, 191, 0.3); color: #d1fae5; }
:host-context(.dark) .md-tag-color-2 { background: rgba(168, 85, 247, 0.28); color: #ede9fe; }
:host-context(.dark) .md-tag-color-3 { background: rgba(245, 158, 11, 0.28); color: #fde68a; }
:host-context(.dark) .md-tag-color-4 { background: rgba(248, 113, 113, 0.3); color: #fee2e2; }
:host-context(.dark) .md-tag-color-5 { background: rgba(99, 102, 241, 0.3); color: #e0e7ff; }
:host-context(.dark) .md-tag-color-6 { background: rgba(217, 70, 239, 0.28); color: #f5d0fe; }
:host-context(.dark) .md-tag-color-7 { background: rgba(34, 197, 94, 0.3); color: #bbf7d0; }
:host-context(.dark) .md-tag-color-8 { background: rgba(59, 130, 246, 0.28); color: #bfdbfe; }
:host-context(.dark) .md-tag-color-9 { background: rgba(250, 204, 21, 0.28); color: #fef08a; }
:host-context(.dark) .md-tag-color-10 { background: rgba(236, 72, 153, 0.3); color: #fbcfe8; }
:host-context(.dark) .md-tag-color-11 { background: rgba(107, 114, 128, 0.3); color: #cbd5f5; }

View File

@ -1,6 +1,12 @@
<div class="fixed inset-0 z-50 flex items-end md:items-center justify-center">
<div class="absolute inset-0 bg-black/40"></div>
<div class="relative rounded-2xl shadow-xl border border-border dark:border-border bg-card dark:bg-main p-4 md:p-5 w-[min(880px,96vw)] h-[92vh] md:h-auto md:max-h-[85vh] overflow-auto pb-24 md:pb-5">
<div class="fixed inset-0 flex items-end md:items-center justify-center" [style.zIndex]="2147483640" [style.pointerEvents]="'auto'" (click)="close.emit()">
<div class="absolute inset-0 bg-black/40" [style.zIndex]="-1"></div>
<div class="relative tag-overlay-panel rounded-2xl shadow-xl border border-border dark:border-border p-4 md:p-5 w-[min(880px,96vw)] h-[92vh] md:h-auto md:max-h-[85vh] overflow-auto pb-24 md:pb-5"
[style.background]="'var(--card)'"
[style.opacity]="1"
[style.filter]="'none'"
[style.backdropFilter]="'none'"
[style.isolation]="'isolate'"
(click)="$event.stopPropagation()">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h.01"/><path d="M3 7h5a2 2 0 0 1 1.414.586l7 7a2 2 0 0 1 0 2.828l-3.172 3.172a2 2 0 0 1-2.828 0l-7-7A2 2 0 0 1 3 12V7Z"/></svg>
@ -18,15 +24,30 @@
<div class="flex flex-wrap items-center gap-1 md:gap-1.5">
<button type="button" class="btn-colored-xs" [class.btn-colored-active]="letter()==='all'" (click)="letter.set('all')">Tous</button>
@for (L of letters; track L) {
<button type="button" class="btn-colored-xs" [class.btn-colored-active]="letter()===L" (click)="letter.set(L)">{{ L }}</button>
<button type="button"
class="btn-colored-xs"
[class.btn-colored-active]="letter()===L"
[class.btn-disabled]="!isLetterEnabled(L)"
[disabled]="!isLetterEnabled(L)"
(click)="letter.set(L)">{{ L }}</button>
}
<button type="button" class="btn-colored-xs" [class.btn-colored-active]="letter()==='#'" (click)="letter.set('#')">#</button>
<button type="button" class="btn-colored-xs" [class.btn-colored-active]="letter()==='other'" (click)="letter.set('other')">Autre</button>
<button type="button"
class="btn-colored-xs"
[class.btn-colored-active]="letter()==='#'"
[class.btn-disabled]="!isLetterEnabled('#')"
[disabled]="!isLetterEnabled('#')"
(click)="letter.set('#')">#</button>
<button type="button"
class="btn-colored-xs"
[class.btn-colored-active]="letter()==='other'"
[class.btn-disabled]="!isLetterEnabled('other')"
[disabled]="!isLetterEnabled('other')"
(click)="letter.set('other')">Autre</button>
</div>
</div>
<!-- Selected tags + input -->
<div class="flex flex-wrap items-center gap-2 rounded-xl border border-border dark:border-border p-3 min-h-[48px]">
<div class="flex flex-wrap items-center gap-2 rounded-xl border border-border dark:border-border p-3 min-h-[48px] bg-card">
@for (t of working(); track t) {
<span class="md-tag-badge" [ngClass]="tagColorClass(t)" [attr.data-tag]="t">
{{ t }}
@ -41,11 +62,11 @@
(input)="inputValue.set($any($event.target).value)"
(keydown)="onKeydown($event)"
placeholder="Ajouter un tag..."
class="min-w-[200px] flex-1 w-full md:w-auto rounded-full border border-border bg-bg-muted/70 py-2.5 px-4 text-sm text-text-main placeholder:text-text-muted shadow-subtle focus:outline-none focus:ring-2 focus:ring-ring transition-all dark:border-border dark:bg-card/80 dark:text-gray-100 dark:placeholder-gray-400 dark:focus:ring-ring" />
class="min-w-[200px] flex-1 w-full md:w-auto rounded-full border border-border bg-bg-muted py-2.5 px-4 text-sm text-text-main placeholder:text-text-muted shadow-subtle focus:outline-none focus:ring-2 focus:ring-ring transition-all dark:border-border dark:bg-card dark:text-gray-100 dark:placeholder-gray-400 dark:focus:ring-ring" />
</div>
<!-- Suggestions -->
<div class="rounded-xl border border-border dark:border-border p-3">
<div class="rounded-xl border border-border dark:border-border p-3 bg-card">
@if (suggestions().length === 0) {
<div class="px-1 py-0.5 text-sm text-muted">Aucune suggestion</div>
} @else {

View File

@ -1,5 +1,5 @@
import { Component, ChangeDetectionStrategy, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, Input, Output, EventEmitter, inject, signal, computed, ElementRef, OnInit, OnDestroy, Renderer2 } from '@angular/core';
import { CommonModule, DOCUMENT } from '@angular/common';
import { uniqueTags, normalizeTag } from '../tag-utils';
import { VaultService } from '../../../../services/vault.service';
import { ToastService } from '../../toast/toast.service';
@ -12,9 +12,15 @@ import { ToastService } from '../../toast/toast.service';
styleUrls: ['./tag-editor-overlay.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TagEditorOverlayComponent {
export class TagEditorOverlayComponent implements OnInit, OnDestroy {
private vault = inject(VaultService);
private toast = inject(ToastService);
private elRef = inject(ElementRef<HTMLElement>);
private renderer = inject(Renderer2);
private doc = inject(DOCUMENT);
private originalParent: Node | null = null;
private originalNextSibling: Node | null = null;
@Input() value: string[] = [];
@Input() allTags: string[] = [];
@ -35,8 +41,42 @@ export class TagEditorOverlayComponent {
letter = signal<string>('all');
readonly letters = Array.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ');
// Compute which letters actually have at least one tag
private usedLetters = computed(() => {
const source = this.allKnownTags();
const set = new Set<string>();
for (const raw of source) {
const t = `${raw || ''}`;
if (!t) continue;
const first = (t[0] || '').toUpperCase();
if (/^[A-Z]$/.test(first)) {
set.add(first);
} else if (/^[0-9]$/.test(first)) {
set.add('#');
} else {
set.add('other');
}
}
return set;
});
isLetterEnabled = (L: string) => {
if (L === 'all') return true;
return this.usedLetters().has(L.toUpperCase());
};
ngOnInit() {
this.working.set(uniqueTags(this.value || []));
// Move host under <body> to avoid any parent stacking/overflow clipping
const host = this.elRef.nativeElement;
if (host && host.parentNode && this.doc?.body) {
this.originalParent = host.parentNode;
this.originalNextSibling = host.nextSibling;
this.doc.body.appendChild(host);
// Ensure host itself doesn't introduce stacking issues
this.renderer.setStyle(host, 'position', 'relative');
this.renderer.setStyle(host, 'z-index', '2147483638');
}
}
tagColorClass(tag: string): string {
@ -140,4 +180,19 @@ export class TagEditorOverlayComponent {
this.saving.set(false);
}
}
ngOnDestroy(): void {
// Remove host to prevent any ghost overlay left in DOM
const host = this.elRef.nativeElement;
try {
if (host && host.parentNode) {
host.parentNode.removeChild(host);
}
} catch {
// ignore
} finally {
this.originalParent = null;
this.originalNextSibling = null;
}
}
}

View File

@ -38,7 +38,7 @@ type CtxAction =
box-shadow: 0 10px 30px rgba(0,0,0,.25);
backdrop-filter: blur(6px);
animation: fadeIn .12s ease-out;
transform-origin: top left;
/* transform-origin is controlled inline based on opening direction */
user-select: none;
/* Theme-aware background and border */
background: var(--card, #ffffff);
@ -90,6 +90,24 @@ type CtxAction =
outline: 2px solid color-mix(in oklab, var(--canvas, #ffffff) 70%, var(--fg, #111827) 15%);
outline-offset: 2px;
}
.clear-dot {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(in oklab, var(--surface-2, #e2e8f0) 75%, transparent 25%);
border: 2px solid color-mix(in oklab, var(--fg, #111827) 35%, transparent 65%);
}
.clear-dot::after {
content: '';
position: absolute;
width: 60%;
height: 2px;
border-radius: 9999px;
background: color-mix(in oklab, var(--canvas, #ffffff) 80%, var(--fg, #111827) 20%);
transform: rotate(-45deg);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--card, #0b1220) 65%, transparent 35%);
}
@keyframes fadeIn { from { opacity:0; transform: scale(.95);} to { opacity:1; transform: scale(1);} }
`],
template: `
@ -101,7 +119,7 @@ type CtxAction =
<div
#menu
class="ctx"
[ngStyle]="{ left: left + 'px', top: top + 'px', position:'fixed' }"
[ngStyle]="{ left: left + 'px', top: top + 'px', position:'fixed', 'transform-origin': transformOrigin }"
role="menu"
(contextmenu)="$event.preventDefault()"
>
@ -133,6 +151,13 @@ type CtxAction =
<div class="sep"></div>
<div class="row">
<div
class="dot clear-dot"
(click)="emitColor(null)"
aria-label="Remove folder color"
role="button"
title="Remove folder color"
></div>
<div *ngFor="let c of colors"
class="dot"
[style.background]="c"
@ -154,7 +179,7 @@ export class ContextMenuComponent implements OnChanges, OnDestroy {
/** Actions/retours */
@Output() action = new EventEmitter<CtxAction>();
@Output() color = new EventEmitter<string>();
@Output() color = new EventEmitter<string | null>();
@Output() closed = new EventEmitter<void>();
/** Palette 8 couleurs */
@ -163,16 +188,26 @@ export class ContextMenuComponent implements OnChanges, OnDestroy {
/** Position corrigée (anti overflow) */
left = 0;
top = 0;
transformOrigin: 'top left' | 'bottom left' = 'top left';
@ViewChild('menu') menuRef?: ElementRef<HTMLElement>;
private removeResize?: () => void;
private removeScroll?: () => void;
private repositionRaf: number | null = null;
constructor(private r2: Renderer2, private host: ElementRef<HTMLElement>) {
// listeners globaux qui ferment le menu
this.removeResize = this.r2.listen('window', 'resize', () => this.reposition());
this.removeScroll = this.r2.listen('window', 'scroll', () => this.reposition());
// Déplacer l'hôte directement sous <body> pour éviter les overflow/transform parents
try {
const el = this.host.nativeElement;
if (el && el.parentElement !== document.body) {
document.body.appendChild(el);
}
} catch {}
}
ngOnChanges(changes: SimpleChanges): void {
@ -181,16 +216,20 @@ export class ContextMenuComponent implements OnChanges, OnDestroy {
this.left = this.x;
this.top = this.y;
// Then reposition for anti-overflow
queueMicrotask(() => this.reposition());
this.scheduleReposition();
}
if ((changes['x'] || changes['y']) && this.visible) {
queueMicrotask(() => this.reposition());
this.scheduleReposition();
}
}
ngOnDestroy(): void {
this.removeResize?.();
this.removeScroll?.();
if (this.repositionRaf != null) {
cancelAnimationFrame(this.repositionRaf);
this.repositionRaf = null;
}
}
/** Ferme le menu */
@ -205,11 +244,28 @@ export class ContextMenuComponent implements OnChanges, OnDestroy {
this.close();
}
emitColor(c: string) {
emitColor(c: string | null) {
this.color.emit(c);
this.close();
}
/** Planifie une correction de positionnement sur la prochaine frame */
private scheduleReposition() {
if (!this.visible) return;
if (this.repositionRaf != null) {
cancelAnimationFrame(this.repositionRaf);
this.repositionRaf = null;
}
const el = this.menuRef?.nativeElement;
if (el) {
el.style.visibility = 'hidden';
}
this.repositionRaf = requestAnimationFrame(() => {
this.repositionRaf = null;
this.reposition();
});
}
/** Corrige la position si le menu sortirait du viewport */
private reposition() {
const el = this.menuRef?.nativeElement;
@ -222,11 +278,26 @@ export class ContextMenuComponent implements OnChanges, OnDestroy {
let left = this.x;
let top = this.y;
// Horizontal anti-overflow
if (left + menuRect.width > vw - 8) left = Math.max(8, vw - menuRect.width - 8);
if (top + menuRect.height > vh - 8) top = Math.max(8, vh - menuRect.height - 8);
if (left < 8) left = 8;
// Vertical positioning: open upwards if it would overflow bottom
const wouldOverflowBottom = top + menuRect.height > vh - 8;
if (wouldOverflowBottom) {
top = Math.max(8, top - menuRect.height);
this.transformOrigin = 'bottom left';
} else {
if (top < 8) top = 8;
if (top + menuRect.height > vh - 8) {
top = Math.max(8, vh - menuRect.height - 8);
}
this.transformOrigin = 'top left';
}
this.left = left;
this.top = top;
el.style.visibility = 'visible';
}
/** Fermer avec ESC */

View File

@ -410,9 +410,17 @@ export class FileExplorerComponent {
}
}
onContextMenuColor(color: string) {
onContextMenuColor(color: string | null) {
if (!this.ctxTarget) return;
if (!color) {
this.folderColors.delete(this.ctxTarget.path);
this.persistFolderColors();
this.ctxVisible.set(false);
this.showNotification(`Folder color removed`, 'success');
return;
}
this.setFolderColor(this.ctxTarget.path, color);
this.persistFolderColors();
// Close context menu after color selection
this.ctxVisible.set(false);
this.showNotification(`Folder color updated`, 'success');

View File

@ -49,7 +49,7 @@ type NoteAction =
mix-blend-mode: normal;
overflow: hidden; /* clip inner cover to rounded corners */
animation: fadeIn .12s ease-out;
transform-origin: top left;
/* transform-origin is controlled inline based on opening direction */
user-select: none;
/* Use theme variable for solid background (themes define solid hex for --card) */
background: var(--card) !important;
@ -127,6 +127,24 @@ type NoteAction =
box-shadow: 0 0 0 2px var(--fg, #111827);
transform: scale(1.1);
}
.color-clear-dot {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(in oklab, var(--surface-2, #e2e8f0) 75%, transparent 25%);
border: 2px solid color-mix(in oklab, var(--fg, #111827) 35%, transparent 65%);
}
.color-clear-dot::after {
content: '';
position: absolute;
width: 60%;
height: 2px;
border-radius: 9999px;
background: color-mix(in oklab, var(--canvas, #ffffff) 80%, var(--fg, #111827) 20%);
transform: rotate(-45deg);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--card, #0b1220) 65%, transparent 35%);
}
.icon {
width: 1.125rem;
height: 1.125rem;
@ -144,7 +162,7 @@ type NoteAction =
<div
#menu
class="ctx"
[ngStyle]="{ left: left + 'px', top: top + 'px', position:'fixed' }"
[ngStyle]="{ left: left + 'px', top: top + 'px', position:'fixed', 'transform-origin': transformOrigin }"
role="menu"
(contextmenu)="$event.preventDefault()"
>
@ -239,18 +257,12 @@ type NoteAction =
role="button"
title="Définir la couleur de la note"></div>
<!-- Clear color option -->
<div class="color-dot"
<div class="color-dot color-clear-dot"
[class.active]="!note?.frontmatter?.color"
style="background: conic-gradient(from 45deg, #ef4444, #f59e0b, #22c55e, #3b82f6, #a855f7, #ef4444);"
(click)="emitColor('')"
attr.aria-label="Aucune couleur"
role="button"
title="Retirer la couleur">
<svg class="icon" style="width: 0.75rem; height: 0.75rem;" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</div>
title="Retirer la couleur"></div>
</div>
</div>
</ng-container>
@ -276,11 +288,13 @@ export class NoteContextMenuComponent implements OnChanges, OnDestroy {
/** Position corrigée (anti overflow) */
left = 0;
top = 0;
transformOrigin: 'top left' | 'bottom left' = 'top left';
@ViewChild('menu') menuRef?: ElementRef<HTMLElement>;
private removeResize?: () => void;
private removeScroll?: () => void;
private repositionRaf: number | null = null;
private toastService = inject(ToastService);
constructor(private r2: Renderer2, private host: ElementRef<HTMLElement>) {
@ -303,16 +317,20 @@ export class NoteContextMenuComponent implements OnChanges, OnDestroy {
this.left = this.x;
this.top = this.y;
// Then reposition for anti-overflow
queueMicrotask(() => this.reposition());
this.scheduleReposition();
}
if ((changes['x'] || changes['y']) && this.visible) {
queueMicrotask(() => this.reposition());
this.scheduleReposition();
}
}
ngOnDestroy(): void {
this.removeResize?.();
this.removeScroll?.();
if (this.repositionRaf != null) {
cancelAnimationFrame(this.repositionRaf);
this.repositionRaf = null;
}
}
/** Ferme le menu */
@ -371,6 +389,23 @@ export class NoteContextMenuComponent implements OnChanges, OnDestroy {
return true; // Pour l'instant, on autorise toujours
}
/** Planifie une correction de positionnement sur la prochaine frame */
private scheduleReposition() {
if (!this.visible) return;
if (this.repositionRaf != null) {
cancelAnimationFrame(this.repositionRaf);
this.repositionRaf = null;
}
const el = this.menuRef?.nativeElement;
if (el) {
el.style.visibility = 'hidden';
}
this.repositionRaf = requestAnimationFrame(() => {
this.repositionRaf = null;
this.reposition();
});
}
/** Corrige la position si le menu sortirait du viewport */
private reposition() {
const el = this.menuRef?.nativeElement;
@ -383,11 +418,29 @@ export class NoteContextMenuComponent implements OnChanges, OnDestroy {
let left = this.x;
let top = this.y;
// Horizontal anti-overflow
if (left + menuRect.width > vw - 8) left = Math.max(8, vw - menuRect.width - 8);
if (top + menuRect.height > vh - 8) top = Math.max(8, vh - menuRect.height - 8);
if (left < 8) left = 8;
// Vertical positioning: open upwards if it would overflow bottom
const wouldOverflowBottom = top + menuRect.height > vh - 8;
if (wouldOverflowBottom) {
// Open upwards from the click point
top = Math.max(8, top - menuRect.height);
this.transformOrigin = 'bottom left';
} else {
// Normal opening downwards; clamp if too close to top
if (top < 8) top = 8;
// Also clamp if still would overflow due to extremely small viewport
if (top + menuRect.height > vh - 8) {
top = Math.max(8, vh - menuRect.height - 8);
}
this.transformOrigin = 'top left';
}
this.left = left;
this.top = top;
el.style.visibility = 'visible';
}
/** Fermer avec ESC */

View File

@ -47,8 +47,28 @@ export interface WikiLinkActivation {
standalone: true,
imports: [CommonModule, NoteHeaderComponent, MarkdownEditorComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [`
.note-viewer-root { position: relative; border-radius: 0.5rem; overflow: visible; }
.note-viewer-root::before {
content: '';
position: absolute;
top: -8px; /* bridge any layout padding gap at the very top */
left: 50%;
transform: translateX(-50%);
width: 100dvw; /* full page width (dynamic viewport) */
height: 320px; /* extended to cover all header + metadata + edit toolbar */
background-image: var(--note-topband-gradient, none);
background-repeat: no-repeat;
background-size: 100% 100%;
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
pointer-events: none;
z-index: 0;
}
.note-viewer-root > * { position: relative; z-index: 1; }
`],
template: `
<div class="relative p-1 prose prose-lg dark:prose-invert max-w-none prose-p:leading-[1] prose-li:leading-[1] prose-blockquote:leading-[1]">
<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="sr-only" role="status" aria-live="polite">{{ copyStatus() }}</div>
<ng-container *ngIf="note() as note">
@ -329,6 +349,7 @@ export class NoteViewerComponent implements OnDestroy {
showToc = output<void>();
directoryClicked = output<string>();
addTagRequested = output<void>();
searchRequested = output<void>();
fullScreenRequested = output<void>();
legacyRequested = output<void>();
parametersRequested = output<void>();
@ -410,6 +431,7 @@ export class NoteViewerComponent implements OnDestroy {
this.scheduleAttachmentHandlers();
this.scheduleMathRender();
this.scheduleWikiLinkHoverHandlers();
this.updateTopBandGradient(this.note());
});
afterNextRender(() => {
@ -470,6 +492,33 @@ export class NoteViewerComponent implements OnDestroy {
});
}
/**
* Apply full-width top band gradient based on frontmatter.color
*/
private updateTopBandGradient(n: Note | undefined): void {
try {
const host = this.elementRef.nativeElement as HTMLElement;
const root = host.querySelector('.note-viewer-root') as HTMLElement | null;
if (!root) return;
const raw = (n?.frontmatter as any)?.color as string | undefined;
let gradient = '';
if (raw) {
let topColor = raw;
const m = /^#([0-9a-fA-F]{6})$/.exec(raw);
if (m) {
const hex = m[1];
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
topColor = `rgba(${r}, ${g}, ${b}, 0.34)`;
}
gradient = `linear-gradient(to bottom, ${topColor} 0%, rgba(0,0,0,0.0) 90%, rgba(0,0,0,0) 100%)`;
}
if (gradient) root.style.setProperty('--note-topband-gradient', gradient);
else root.style.removeProperty('--note-topband-gradient');
} catch {}
}
// La sauvegarde des tags est maintenant gérée automatiquement par TagsEditorComponent
// Cette méthode met simplement à jour l'état local après sauvegarde
onTagsChange(next: string[]): void {
@ -748,21 +797,10 @@ export class NoteViewerComponent implements OnDestroy {
private ensureMermaid(): Promise<MermaidLib> {
if (this.mermaidLib) return Promise.resolve(this.mermaidLib);
if (this.mermaidLoader) return this.mermaidLoader;
if (typeof window === 'undefined') return Promise.reject(new Error('Mermaid is only available in the browser environment.'));
this.mermaidLoader = import('mermaid')
.then(module => {
const mermaidLib = (module.default ?? module) as MermaidLib;
const prefersDark = document.documentElement.classList.contains('dark');
mermaidLib.initialize({ startOnLoad: false, securityLevel: 'loose', theme: prefersDark ? 'dark' : 'default' });
this.mermaidLib = mermaidLib;
return mermaidLib;
})
.catch(error => {
this.mermaidLoader = null;
throw error;
});
return this.mermaidLoader;
const prefersDark = document.documentElement.classList.contains('dark');
mermaid.initialize({ startOnLoad: false, securityLevel: 'loose', theme: prefersDark ? 'dark' : 'default' });
this.mermaidLib = mermaid as unknown as MermaidLib;
return Promise.resolve(this.mermaidLib);
}
private renderMermaidDiagrams(): void {

View File

@ -9,6 +9,31 @@
@import './styles/codemirror.css';
/* Local-only fallbacks: use installed fonts if available */
/* In-page search highlight */
.ov-mark {
background-color: rgba(253, 224, 71, 0.4); /* yellow-300/40 */
outline: 1px solid rgba(253, 224, 71, 0.4);
border-radius: 0.25rem;
padding: 0 0.125rem;
}
.dark .ov-mark {
background-color: rgba(253, 224, 71, 0.3);
outline-color: rgba(253, 224, 71, 0.4);
}
.ov-mark-active {
outline-width: 2px;
outline-color: rgba(234, 179, 8, 0.9); /* yellow-500 */
}
/* Sidebar width variables (default values) */
:root {
--sidebar-open: 280px;
--sidebar-closed: 64px;
--sidebar-width: var(--sidebar-open);
}
@font-face {
font-family: 'gg sans';
font-style: normal;

View File

@ -0,0 +1,17 @@
---
titre: Nouvelle note 14
auteur: Bruno Charest
creation_date: 2025-10-27T01:39:31.961Z
modification_date: 2025-10-26T21:39:32-04:00
catégorie: ""
tags: []
aliases: []
status: en-cours
publish: false
favoris: false
template: false
task: false
archive: false
draft: false
private: false
---

83
vault/Allo-3/page test.md Normal file
View File

@ -0,0 +1,83 @@
---
titre: "page test"
auteur: "Bruno Charest"
creation_date: "2025-10-26T13:57:39-04:00"
modification_date: "2025-10-26T13:57:40-04:00"
tags: [""]
aliases: [""]
status: "en-cours"
publish: false
favoris: false
template: false
task: false
archive: false
draft: false
private: false
color: "#22C55E"
---
# Page de Test Markdown
## Section 1
### Sous-section 1.1
## Section 2
### Sous-section 2.1
### Sous-section 2.2
### Sous-section 2.3
# tableau:
| Colonne 1 | Colonne 2 | Colonne 3 |
|---------|---------|---------|
| Cellule 1 | Cellule 2 | Cellule 3 |
| Cellule 4 | Cellule 5 | Cellule 6 |
# Liens
[lien](https://google.com)
# Liste
- item 1
- item 2
- item 3
# Liste à puces
- item 1
- item 2
- item 3
# Liste à numérotation
1. item 1
2. item 2
3. item 3
# Bloc de code
```javascript
console.log('hello world');
```
# Bloc de citation
> citation
# Bloc de citation avec plusieurs lignes
> citation
> avec plusieurs lignes
# Bloc de citation avec plusieurs lignes et bloc de code
> citation
> avec plusieurs lignes
```javascript
console.log('hello world');
```

View File

@ -17,3 +17,69 @@ archive: false
draft: false
private: false
---
# Page de Test Markdown
## Section 1
### Sous-section 1.1
## Section 2
### Sous-section 2.1
### Sous-section 2.2
### Sous-section 2.3
# tableau:
| Colonne 1 | Colonne 2 | Colonne 3 |
|---------|---------|---------|
| Cellule 1 | Cellule 2 | Cellule 3 |
| Cellule 4 | Cellule 5 | Cellule 6 |
# Liens
[lien](https://google.com)
# Liste
- item 1
- item 2
- item 3
# Liste à puces
- item 1
- item 2
- item 3
# Liste à numérotation
1. item 1
2. item 2
3. item 3
# Bloc de code
```javascript
console.log('hello world');
```
# Bloc de citation
> citation
# Bloc de citation avec plusieurs lignes
> citation
> avec plusieurs lignes
# Bloc de citation avec plusieurs lignes et bloc de code
> citation
> avec plusieurs lignes
```javascript
console.log('hello world');
```

View File

@ -1,14 +1,9 @@
---
titre: Nouvelle note 3
auteur: Bruno Charest
creation_date: 2025-10-24T15:44:07.120Z
modification_date: 2025-10-25T19:53:45-04:00
catégorie: ""
tags:
- ""
aliases:
- ""
status: en-cours
titre: "Nouvelle note 3"
auteur: "Bruno Charest"
creation_date: "2025-10-24T15:44:07.120Z"
modification_date: "2025-10-25T19:53:45-04:00"
status: "en-cours"
publish: false
favoris: false
template: false

View File

@ -1,12 +1,10 @@
---
titre: Nouvelle note 1
auteur: Bruno Charest
creation_date: 2025-10-24T03:30:58.977Z
modification_date: 2025-10-23T23:30:59-04:00
catégorie: ""
tags: []
aliases: []
status: en-cours
titre: "Nouvelle note 1"
auteur: "Bruno Charest"
creation_date: "2025-10-24T03:30:58.977Z"
modification_date: "2025-10-23T23:30:59-04:00"
aliases: [""]
status: "en-cours"
publish: false
favoris: false
template: false

View File

@ -12,6 +12,7 @@ archive: true
draft: true
private: true
toto: "tata"
color: "#EF4444"
---
Allo ceci est un tests
toto

View File

@ -1,14 +1,9 @@
---
titre: Nouveau-markdown
auteur: Bruno Charest
creation_date: 2025-10-19T21:42:53-04:00
modification_date: 2025-10-19T21:43:06-04:00
catégorie: markdown
tags:
- allo
aliases:
- nouveau
status: en-cours
titre: "Nouveau-markdown"
auteur: "Bruno Charest"
creation_date: "2025-10-19T21:42:53-04:00"
modification_date: "2025-10-19T21:43:06-04:00"
status: "en-cours"
publish: true
favoris: true
template: true
@ -16,8 +11,9 @@ task: true
archive: true
draft: true
private: true
toto: tata
toto: "tata"
readOnly: false
color: "#A855F7"
---
Allo ceci est un tests
toto

View File

@ -3,14 +3,16 @@ titre: "Nouvelle note 10"
auteur: "Bruno Charest"
creation_date: "2025-10-26T14:55:55.330Z"
modification_date: "2025-10-26T10:55:55-04:00"
tags: [""]
aliases: [""]
status: "en-cours"
publish: false
favoris: false
template: false
task: false
archive: false
draft: false
private: false
publish: true
favoris: true
template: true
task: true
archive: true
draft: true
private: true
color: "#EF4444"
---
# TEST

View File

@ -1,12 +1,10 @@
---
titre: Nouvelle note 13
auteur: Bruno Charest
creation_date: 2025-10-26T14:56:06.375Z
modification_date: 2025-10-26T10:56:06-04:00
catégorie: ""
tags: []
aliases: []
status: en-cours
titre: "Nouvelle note 13"
auteur: "Bruno Charest"
creation_date: "2025-10-26T14:56:06.375Z"
modification_date: "2025-10-26T10:56:06-04:00"
aliases: [""]
status: "en-cours"
publish: false
favoris: false
template: false
@ -14,4 +12,10 @@ task: false
archive: false
draft: false
private: false
color: "#F59E0B"
tags:
- configuration
- tag2
- bruno
- accueil
---

View File

@ -0,0 +1,17 @@
---
titre: Nouvelle note 14
auteur: Bruno Charest
creation_date: 2025-10-27T02:36:45.394Z
modification_date: 2025-10-26T22:36:45-04:00
catégorie: ""
tags: []
aliases: []
status: en-cours
publish: false
favoris: true
template: false
task: false
archive: false
draft: false
private: false
---

View File

@ -1,12 +1,11 @@
---
titre: Nouvelle note 8
auteur: Bruno Charest
creation_date: 2025-10-26T14:55:08.540Z
modification_date: 2025-10-26T10:55:08-04:00
catégorie: ""
tags: []
aliases: []
status: en-cours
titre: "Nouvelle note 8"
auteur: "Bruno Charest"
creation_date: "2025-10-26T14:55:08.540Z"
modification_date: "2025-10-26T10:55:08-04:00"
tags: [""]
aliases: [""]
status: "en-cours"
publish: false
favoris: false
template: false
@ -14,4 +13,5 @@ task: false
archive: false
draft: false
private: false
color: "#F59E0B"
---

View File

@ -1,17 +1,20 @@
---
titre: test-add-properties
auteur: Bruno Charest
creation_date: 2025-10-23T13:11:50-04:00
modification_date: 2025-10-23T13:11:50-04:00
catégorie: ""
tags: []
aliases: []
status: en-cours
titre: "test-add-properties"
auteur: "Bruno Charest"
creation_date: "2025-10-23T13:11:50-04:00"
modification_date: "2025-10-23T13:11:50-04:00"
aliases: [""]
status: "en-cours"
publish: false
favoris: false
favoris: true
template: false
task: false
task: true
archive: false
draft: false
private: false
private: true
color: "#22C55E"
tags:
- tag1
- accueil
- tag3
---

View File

@ -0,0 +1,17 @@
---
titre: Nouvelle note 10
auteur: Bruno Charest
creation_date: 2025-10-27T01:39:29.029Z
modification_date: 2025-10-26T21:39:29-04:00
catégorie: ""
tags: []
aliases: []
status: en-cours
publish: false
favoris: false
template: false
task: false
archive: false
draft: false
private: false
---

View File

@ -1,8 +1,8 @@
---
titre: "Nouvelle note 10"
auteur: "Bruno Charest"
creation_date: "2025-10-26T14:55:55.330Z"
modification_date: "2025-10-26T14:55:55.330Z"
creation_date: "2025-10-27T01:39:29.029Z"
modification_date: "2025-10-27T01:39:29.029Z"
status: "en-cours"
publish: false
favoris: false

View File

@ -0,0 +1,17 @@
---
titre: Nouvelle note 11
auteur: Bruno Charest
creation_date: 2025-10-27T01:39:30.678Z
modification_date: 2025-10-26T21:39:31-04:00
catégorie: ""
tags: []
aliases: []
status: en-cours
publish: false
favoris: false
template: false
task: false
archive: false
draft: false
private: false
---

View File

@ -1,8 +1,8 @@
---
titre: "Nouvelle note 13"
titre: "Nouvelle note 11"
auteur: "Bruno Charest"
creation_date: "2025-10-26T14:56:06.375Z"
modification_date: "2025-10-26T14:56:06.375Z"
creation_date: "2025-10-27T01:39:30.678Z"
modification_date: "2025-10-27T01:39:30.678Z"
status: "en-cours"
publish: false
favoris: false

View File

@ -1,10 +1,8 @@
---
titre: "Nouvelle note 3"
titre: "Nouvelle note 14"
auteur: "Bruno Charest"
creation_date: "2025-10-24T15:44:07.120Z"
modification_date: "2025-10-24T11:44:07-04:00"
tags: [""]
aliases: [""]
creation_date: "2025-10-27T01:39:31.961Z"
modification_date: "2025-10-27T01:39:31.961Z"
status: "en-cours"
publish: false
favoris: false
@ -14,3 +12,4 @@ archive: false
draft: false
private: false
---

View File

@ -0,0 +1,17 @@
---
titre: Nouvelle note 15
auteur: Bruno Charest
creation_date: 2025-10-27T01:39:33.465Z
modification_date: 2025-10-26T21:39:33-04:00
catégorie: ""
tags: []
aliases: []
status: en-cours
publish: false
favoris: false
template: false
task: false
archive: false
draft: false
private: false
---

View File

@ -0,0 +1,15 @@
---
titre: "Nouvelle note 15"
auteur: "Bruno Charest"
creation_date: "2025-10-27T01:39:33.465Z"
modification_date: "2025-10-27T01:39:33.465Z"
status: "en-cours"
publish: false
favoris: false
template: false
task: false
archive: false
draft: false
private: false
---

View File

@ -1,21 +1,6 @@
---
titre: test-drawing.excalidraw
auteur: Bruno Charest
creation_date: 2025-10-10T09:55:20-04:00
modification_date: 2025-10-19T12:09:47-04:00
catégorie: ""
tags:
- excalidraw
aliases: []
status: en-cours
publish: false
favoris: false
template: false
task: false
archive: false
draft: false
private: false
excalidraw-plugin: parsed
tags: [excalidraw]
---
==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==
You can decompress Drawing data with the command palette: 'Decompress current Excalidraw file'. For more info check in plugin settings under 'Saving'
@ -26,74 +11,6 @@ You can decompress Drawing data with the command palette: 'Decompress current Ex
%%
## Drawing
```compressed-json
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
unsu
N4IgpgNmC2YHYBcDOIBcBtUBLAJmkA1gCIQAsA7AHIBuWAzALQCuAjnOQOoDyAngKIALACp0QAGhAIeABzD4ATmADGCAIZwA5lHEgAHmjoBWAEwSeaAIwWAHBIDuuBAMvlDAOkMSBYLBoEJLUndPEHUtOVQABgkkBHkAewIwAGF4iHj5fABiCzBc3J0AI1UlAg0EpjgcVPTM1El5dSRpVUVEHQAzLAgIAGUpbXqkNNwdWISkjkdnVFMQccSwfp5B+ZG8CQq/ODAkFFQLCXiWpSwpS0jokHL4pmkASRx99ABdCQ7G2Ee0OCYezduVR2ezQoCksgMAF8YmAwHgDgBOUg2IKGABsCIk1DA8iQWHicEsthA2Nx+LglAJSgiNjRpGMCMMQXIEiwSCIkDACDhaA6qggSDAEkKgJwfCgsEQ+1+/xAdxwqm58Is5DRFkMdDoDOMhl1EggWDgBB+fwg+vipR5qD5ArAkLeoWk0n6ioioCQAnidg4kCU8VgvSUingaDiTCFkm8sHwCvkxokfp6qhFjQQGX2wGhICUTHkbQQyQErQQQhkERAxUyCdz+fu3OgACESmUKlUahl8HEmi18zoc3n4Ag6zA+FUAIJ5r3eVTw0KTux9muD4fQABi3T6A3LwwNG2zS8QK9XBIQq9U0G65gO1YHh/rx8QvSwAC8IsYrv3a/WuCczleLJcN5fjAABKtzbLs+yHPut5DvWLryAgE4JHY07wjKZowcB0D9BMKRpB29Q5HkJGLrBK5gZUODAvsICtnun7LvBcSLMsqw7qMQFMTAuGLFMOBOGgcyMXeMBCGAuhIQaGiEvUUAdAEN7DPIDZMAgaayXK0g6CUCBYNifDQIUcIKoUgwYRIOCNBoGiGho4owIOJqynCZx2Q5koBKgFngDgbmaAA4hUDzoaaEiuXpmgADKGmArQeU53lhaEKj6WAQjxGkoKSGW+CClAqUEousT+qWEJJbK6SWvCNqCvqqixGOqXYhlWUVRAWayHAACy8Q4BEtURl1HLcioVqDeFk7yN1kGqBoEQ+RJ0gZAgTalDcVGhvI4bhboy2IYG/Jvrt+0IIZxk4IG8ADfydXgHtK1TE4RCtAQvX9byt0Rl0UAABLqDg5nJeUuBPq+zmYWyDaGn5mijsmUDwmGEb9XyfwIE+/WVkQFpJDgAAKigdDi8DUp9tqskgUXxDOdnk3dbIgbsL509aX2U2BaiRRo9MRhADUIPj8SGty8g43YcBPTMIDQLcgo6NA6NYAl7TtRIcDnuWACqiBnIjDDvjqDAAQbaIG8YpDWH2J4SQgM2/BDRxdfbTCOyAxzwEL0h3G7HtwJjYCVr7w1YPy8Q82rIAtLEYBEKH6QR+6noS7zlmKqoEOdYotBy70nJjWKErLk8oJZooeLPu5Reqz5SBBmkiNcGpeIfWzFPzPXPQABpoFcdcJD0ACavcwgVSoq0OJeoKAxBkFQtCMKw7DcPwwiiKgyNZvlyhKkFtwhRmW/5+P1fIBOYANj4mhENZ801ez8zH+SE9ux6twQNUJTePcMkZGAABamVoCp3mMnF0yAQGxGLA2UUL9I5ICYDZXYSpoZVDss8B0HxNZMyqDiVmoB4AIytMjBMBptIb22hGDW0YKE7XdmpA0OwtrhizFg2AGVfq+AENJfwbsIp2VXJ8BayVOSeSQBwrhPCvI+TTALN2tAwB2DWi2UU7Y6ggCyB0awHQER8h0K+HqfUwDwzMuNB+z4gHZWoPycMlgswKLsO9YxGtTH33bl1WGGh7jQDmsY0+3x4HJ1+mWeQjCCBex9m3O628C4xR2PFU+r8NbSDibsNArwjjyF8IaXoySuAdA6IKaRyV4iFAAFY7yQLk1Q0gnEmMRiA+IBSilRTAApAwjIjjNK5BlchKpiQOAEjMBE1gQjeC4V5JkaJWHdDSdPSEkIgA=
```
%%
%%

View File

@ -1,84 +0,0 @@
---
excalidraw-plugin: parsed
tags: [excalidraw]
---
==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==
You can decompress Drawing data with the command palette: 'Decompress current Excalidraw file'. For more info check in plugin settings under 'Saving'
# Excalidraw Data
## Text Elements
%%
## Drawing
```compressed-json
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
unsu
```
%%