feat: add toast notifications and improve tag management UI

This commit is contained in:
Bruno Charest 2025-10-16 22:25:05 -04:00
parent 3b7a4326a5
commit 4dbcf8ad81
15 changed files with 577 additions and 42 deletions

View File

@ -1,4 +1,5 @@
<!-- ObsiViewer - Application optimisée pour mobile et desktop -->
<app-toast-container></app-toast-container>
@if (uiMode.isNimbusMode()) {
<app-shell-nimbus-layout
[vaultName]="vaultName()"

View File

@ -33,6 +33,7 @@ import { GraphIndexService } from './core/graph/graph-index.service';
import { SearchIndexService } from './core/search/search-index.service';
import { SearchOrchestratorService } from './core/search/search-orchestrator.service';
import { LayoutModule } from '@angular/cdk/layout';
import { ToastContainerComponent } from './app/shared/toast/toast-container.component';
// Types
import { FileMetadata, Note, TagInfo, VaultNode } from './types';
@ -63,6 +64,7 @@ interface TocEntry {
DrawingsEditorComponent,
AppShellNimbusLayoutComponent,
MarkdownPlaygroundComponent,
ToastContainerComponent,
],
templateUrl: './app.component.simple.html',
styleUrls: ['./app.component.css'],

View File

@ -92,7 +92,7 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
</aside>
<!-- Flyouts -->
<div class="absolute left-14 top-0 bottom-0 w-80 max-w-[70vw] bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 shadow-xl" *ngIf="hoveredFlyout as f" (mouseenter)="cancelCloseFlyout()" (mouseleave)="scheduleCloseFlyout()">
<div class="absolute left-14 top-0 bottom-0 w-80 max-w-[70vw] bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 shadow-xl z-50" *ngIf="hoveredFlyout as f" (mouseenter)="cancelCloseFlyout()" (mouseleave)="scheduleCloseFlyout()">
<div class="h-12 flex items-center justify-between px-3 border-b border-gray-200 dark:border-gray-800">
<div class="text-sm font-semibold">{{ f === 'quick' ? 'Quick Links' : (f === 'folders' ? 'Folders' : (f === 'tags' ? 'Tags' : 'Trash')) }}</div>
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (click)="hoveredFlyout=null"></button>
@ -192,17 +192,25 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
<div *ngIf="responsive.isMobile() && !noteFullScreen" class="flex-1 relative overflow-hidden" appSwipeNav (swipeLeft)="nextTab()" (swipeRight)="prevTab()">
<app-sidebar-drawer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" (noteSelected)="onNoteSelectedMobile($event)" (folderSelected)="onFolderSelectedFromDrawer($event)"></app-sidebar-drawer>
<div [hidden]="mobileNav.activeTab() !== 'list'" class="h-full flex flex-col overflow-hidden">
@if (mobileNav.activeTab() === 'list') {
<div class="h-full flex flex-col overflow-hidden">
<app-notes-list class="flex-1" [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [query]="listQuery" (queryChange)="listQuery=$event" (openNote)="onNoteSelectedMobile($event)"></app-notes-list>
</div>
}
<div [hidden]="mobileNav.activeTab() !== 'page'" class="h-full overflow-y-auto px-3 py-3" appScrollableOverlay>
@if (mobileNav.activeTab() === 'page') {
<div class="h-full overflow-y-auto px-3 py-3" appScrollableOverlay>
<div class="flex items-center justify-between mb-2">
<h2 class="text-base font-semibold truncate">{{ selectedNote?.title || 'Aucune page' }}</h2>
<button *ngIf="tableOfContents.length > 0" (click)="mobileNav.toggleToc()" class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800">📋</button>
<button *ngIf="tableOfContents.length > 0" (click)="mobileNav.toggleToc()" class="p-2 rounded hover:bg-gray-100 dark:hover-bg-gray-800">📋</button>
</div>
@if (selectedNote) {
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="tagClicked.emit($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)" (fullScreenRequested)="toggleNoteFullScreen()"></app-note-viewer>
} @else {
<div class="mt-10 text-center text-sm text-gray-500 dark:text-gray-400">Aucune page sélectionnée pour le moment.</div>
}
</div>
}
<app-toc-overlay *ngIf="mobileNav.tocOpen()" [headings]="tableOfContents" (go)="navigateHeading.emit($event); mobileNav.toggleToc()" (close)="mobileNav.toggleToc()"></app-toc-overlay>

View File

@ -0,0 +1,29 @@
export function rewriteTagsFrontmatter(rawMarkdown: string, tags: string[]): string {
const content = rawMarkdown.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)/);
const buildTagsBlock = (list: string[]) => {
const uniq = Array.from(new Set(list.map(t => `${t}`.trim()).filter(Boolean)));
if (!uniq.length) return 'tags: []\n';
return ['tags:', ...uniq.map(t => ` - ${t}`)].join('\n') + '\n';
};
if (!fmMatch) {
const tagsBlock = buildTagsBlock(tags);
return `---\n${tagsBlock}---\n` + content;
}
const fmText = fmMatch[1];
const body = fmMatch[2] || '';
// Replace existing tags block if present
const tagsRe = /(^|\n)tags\s*:[^\n]*\n(?:\s*-\s*.*\n)*/i;
const newTagsBlock = `\n${buildTagsBlock(tags)}`;
if (tagsRe.test(fmText)) {
const replaced = fmText.replace(tagsRe, newTagsBlock);
return `---\n${replaced}\n---\n${body}`.replace(/\n{3,}/g, '\n\n');
}
// Append tags block at the end of frontmatter
const fmWithTags = fmText.replace(/\n*$/, '\n') + buildTagsBlock(tags);
return `---\n${fmWithTags}---\n${body}`;
}

View File

@ -0,0 +1,59 @@
<!-- Read-only chips with lucide tag icon; click to edit -->
<div class="not-prose group relative" (click)="enterEdit()">
<div *ngIf="!editing(); else editTpl" class="flex items-center gap-2 text-sm ml-2">
<span class="text-text-muted inline-flex items-center pr-1">
<!-- lucide tag icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.5 7.5h.01"/><path d="M3 7.5V3h4.5l7 7a3 3 0 0 1 0 4.243l-2.257 2.257a3 3 0 0 1-4.243 0l-7-7Z"/></svg>
</span>
<ng-container *ngIf="_tags().length; else emptyTpl">
<div class="flex flex-wrap gap-2 max-h-[3.2rem] overflow-hidden">
<ng-container *ngFor="let t of _tags().slice(0,3); let i = index">
<span class="inline-flex items-center gap-1 rounded-full bg-bg-muted/80 px-2 py-0.5 text-xs font-medium text-text-main border border-border transition-colors hover:bg-gray-700">
{{ t }}
</span>
</ng-container>
<ng-container *ngIf="_tags().length > 3">
<span class="inline-flex items-center rounded-full bg-bg-muted px-2 py-0.5 text-xs font-semibold text-text-muted border border-border" title="{{ _tags().slice(3).join(', ') }}">+{{ _tags().length - 3 }}</span>
</ng-container>
</div>
</ng-container>
<ng-template #emptyTpl>
<span class="text-text-muted text-xs">Ajouter des tags…</span>
</ng-template>
</div>
</div>
<ng-template #editTpl>
<div class="relative w-full">
<div class="flex flex-wrap items-center gap-2 rounded-xl border border-border bg-gray-800/50 px-3 py-2 shadow-subtle focus-within:ring-2 focus-within:ring-blue-500/40 transition-all duration-200 ease-out">
<ng-container *ngFor="let t of _tags(); let i = index">
<span class="inline-flex items-center gap-1 rounded-full bg-gray-700/60 px-2 py-0.5 text-xs font-medium text-gray-200 border border-border transition-all duration-150 hover:bg-blue-500/80"
[ngClass]="{ 'ring-1 ring-red-400': highlightedIndex() === i }">
{{ t }}
<button type="button" class="ml-1 opacity-70 hover:opacity-100" (click)="removeTagAt(i)" aria-label="Supprimer">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</span>
</ng-container>
<input data-tag-input type="text" class="min-w-[8ch] flex-1 bg-transparent text-sm outline-none placeholder:text-text-muted" [value]="inputValue()" (input)="onInput($event)" (keydown)="onInputKeydown($event)" (blur)="blurEdit()" placeholder="Rechercher ou créer un tag" />
</div>
<!-- Suggestions dropdown -->
<div *ngIf="menuOpen()" class="absolute left-0 top-[calc(100%+6px)] z-50 max-h-[300px] w-[min(420px,92vw)] overflow-auto rounded-lg border border-border bg-card shadow-xl">
<ng-container *ngIf="suggestions().length; else createTpl">
<button type="button" *ngFor="let s of suggestions(); let i = index" (mousedown)="$event.preventDefault()" (click)="pickSuggestion(i)"
class="block w-full text-left px-3 py-2 text-sm transition-colors hover:bg-bg-muted"
[ngClass]="{ 'bg-bg-muted': menuIndex() === i, 'opacity-60 cursor-not-allowed': isSelected(s) }"
[attr.aria-disabled]="isSelected(s)">
<span>{{ s }}</span>
<span *ngIf="isSelected(s)" class="float-right text-xs text-text-muted">déjà sélectionné</span>
</button>
</ng-container>
<ng-template #createTpl>
<button type="button" class="block w-full text-left px-3 py-2 text-sm hover:bg-bg-muted" (mousedown)="$event.preventDefault()" (click)="pickSuggestion(-1)">
Create {{ inputValue().trim() }}
</button>
</ng-template>
</div>
</div>
</ng-template>

View File

@ -0,0 +1,184 @@
import { Component, ChangeDetectionStrategy, ElementRef, HostListener, Input, Output, EventEmitter, computed, effect, inject, signal } from '@angular/core';
import { CommonModule, NgFor, NgIf, NgClass } from '@angular/common';
import { VaultService } from '../../../services/vault.service';
import { ToastService } from '../toast/toast.service';
@Component({
selector: 'app-tags-editor',
standalone: true,
imports: [CommonModule, NgFor, NgIf, NgClass],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './tags-editor.component.html'
})
export class TagsEditorComponent {
private host = inject(ElementRef<HTMLElement>);
private vault = inject(VaultService);
private toast = inject(ToastService);
@Input() noteId = '';
private readonly externalTags = signal<string[]>([]);
@Input() set tags(value: string[] | null | undefined) {
const normalized = this.normalizeTags(value);
this.externalTags.set(normalized);
if (!this.editing()) {
this._tags.set([...normalized]);
}
}
get tags(): string[] { return this.externalTags(); }
@Output() tagsChange = new EventEmitter<string[]>();
@Output() commit = new EventEmitter<void>();
readonly _tags = signal<string[]>([]);
readonly editing = signal(false);
readonly inputValue = signal('');
readonly hover = signal(false);
readonly focusedChip = signal<number | null>(null);
readonly menuOpen = signal(false);
readonly menuIndex = signal<number>(-1);
readonly highlightedIndex = signal<number | null>(null);
// Suggestions from vault, filtered on prefix (case-insensitive)
readonly allTagNames = computed(() => this.vault.tags().map(t => t.name));
readonly suggestions = computed(() => {
const q = this.inputValue().trim().toLowerCase();
const exist = new Set(this._tags().map(t => t.toLowerCase()));
const base = this.allTagNames();
const pool = q.length < 1 ? base : base.filter(name => name.toLowerCase().startsWith(q));
return pool.slice(0, 200);
});
isSelected(name: string): boolean {
const lower = name.toLowerCase();
return this._tags().some(t => t.toLowerCase() === lower);
}
// Public API
enterEdit() {
this._tags.set([...this.externalTags()]);
this.editing.set(true);
queueMicrotask(() => this.focusInput());
this.menuOpen.set(true);
this.menuIndex.set(0);
}
blurEdit() {
this.editing.set(false);
this.menuOpen.set(false);
this.menuIndex.set(-1);
this.commit.emit();
this._tags.set([...this.externalTags()]);
}
addTagFromInput() {
const raw = this.inputValue().trim();
if (!raw) return;
this.addTag(raw);
}
addTag(tag: string) {
const value = `${tag}`.trim();
if (!value) return;
const existing = this._tags();
const lower = value.toLowerCase();
const existingIndex = existing.findIndex(t => t.toLowerCase() === lower);
if (existingIndex === -1) {
const next = [...existing, value];
this._tags.set(next);
this.tagsChange.emit(next);
} else {
// Visual feedback + toast
this.highlightedIndex.set(existingIndex);
setTimeout(() => this.highlightedIndex.set(null), 600);
this.toast.info('Tag déjà présent');
return;
}
this.inputValue.set('');
this.menuIndex.set(0);
this.menuOpen.set(true);
this.focusInput();
}
removeTagAt(index: number) {
const next = this._tags().slice();
next.splice(index, 1);
this._tags.set(next);
this.tagsChange.emit(next);
this.menuOpen.set(true);
this.menuIndex.set(Math.min(index, next.length - 1));
this.focusInput();
}
onInput(ev: Event) {
const v = (ev.target as HTMLInputElement).value || '';
this.inputValue.set(v);
this.menuOpen.set(true);
this.menuIndex.set(v.trim() ? -1 : 0);
}
pickSuggestion(i: number) {
const list = this.suggestions();
if (i >= 0 && i < list.length) {
if (this.isSelected(list[i])) return; // ignore selected
this.addTag(list[i]);
} else {
// Create option
const q = this.inputValue().trim();
if (q) this.addTag(q);
}
}
focusInput() {
const el = this.host.nativeElement.querySelector('input[data-tag-input]') as HTMLInputElement | null;
el?.focus();
}
// Keyboard handling on the input
onInputKeydown(ev: KeyboardEvent) {
if (ev.key === 'Enter') {
ev.preventDefault();
if (this.menuOpen() && this.menuIndex() >= 0) {
this.pickSuggestion(this.menuIndex());
} else {
this.addTagFromInput();
}
return;
}
if (ev.key === 'Backspace' && !this.inputValue()) {
const arr = this._tags();
if (arr.length) this.removeTagAt(arr.length - 1);
return;
}
if (ev.key === 'ArrowDown') {
ev.preventDefault();
const max = this.suggestions().length - 1;
if (max >= 0) this.menuIndex.set(Math.min(max, this.menuIndex() + 1));
this.menuOpen.set(true);
return;
}
if (ev.key === 'ArrowUp') {
ev.preventDefault();
const max = this.suggestions().length - 1;
if (max >= 0) this.menuIndex.set(Math.max(0, this.menuIndex() - 1));
return;
}
if (ev.key === 'Escape') {
this.menuOpen.set(false);
this.menuIndex.set(-1);
return;
}
}
private normalizeTags(value: string[] | null | undefined): string[] {
if (!value || !Array.isArray(value)) return [];
const ordered = new Map<string, string>();
for (const raw of value) {
const trimmed = `${raw ?? ''}`.trim();
if (!trimmed) continue;
const lower = trimmed.toLowerCase();
if (!ordered.has(lower)) {
ordered.set(lower, trimmed);
}
}
return Array.from(ordered.values());
}
}

View File

@ -0,0 +1,3 @@
export * from './toast.model';
export * from './toast.service';
export * from './toast-container.component';

View File

@ -0,0 +1,32 @@
<!-- Container fixe bas-droite -->
<div class="pointer-events-none fixed bottom-4 right-4 z-[9999] flex w-[min(92vw,420px)] flex-col gap-2">
<div
*ngFor="let t of toasts()"
class="pointer-events-auto overflow-hidden rounded-2xl border shadow-lg transition-all duration-300 ease-out data-[closing=true]:translate-y-2 data-[closing=true]:opacity-0 bg-slate-800/90 text-slate-50 border-slate-700"
[attr.data-closing]="t.closing ? 'true' : 'false'"
>
<!-- Contenu -->
<div class="flex items-start gap-3 p-4">
<div class="mt-0.5 text-xl leading-none">
<span *ngIf="t.type==='info'">💬</span>
<span *ngIf="t.type==='success'"></span>
<span *ngIf="t.type==='warning'">⚠️</span>
<span *ngIf="t.type==='error'"></span>
</div>
<div class="flex-1 text-sm leading-relaxed">{{ t.message }}</div>
<button class="ml-1 rounded-md px-2 py-1 text-xs opacity-70 hover:opacity-100" (click)="dismiss(t.id)" aria-label="Fermer"></button>
</div>
<!-- Barre de progression en haut -->
<div class="relative h-1 w-full bg-transparent">
<div class="absolute left-0 top-0 h-1 transition-[width] duration-100 ease-linear"
[ngClass]="{
'bg-sky-500': t.type==='info',
'bg-emerald-500': t.type==='success',
'bg-amber-500': t.type==='warning',
'bg-rose-500': t.type==='error'
}"
[style.width.%]="t.progress * 100"></div>
</div>
</div>
</div>

View File

@ -0,0 +1,15 @@
import { Component, computed, inject } from '@angular/core';
import { NgFor, NgIf, NgClass } from '@angular/common';
import { ToastService } from './toast.service';
@Component({
selector: 'app-toast-container',
standalone: true,
imports: [NgFor, NgIf, NgClass],
templateUrl: './toast-container.component.html'
})
export class ToastContainerComponent {
private svc = inject(ToastService);
toasts = computed(() => this.svc.toasts().slice().reverse());
dismiss(id: string) { this.svc.dismiss(id); }
}

View File

@ -0,0 +1,11 @@
export type ToastType = 'info' | 'success' | 'warning' | 'error';
export interface Toast {
id: string;
type: ToastType;
message: string;
duration: number; // en ms
startedAt: number; // performance.now()
progress: number; // 1 → 0
closing?: boolean; // pour transition de sortie
}

View File

@ -0,0 +1,59 @@
import { Injectable, signal } from '@angular/core';
import { Toast } from './toast.model';
@Injectable({ providedIn: 'root' })
export class ToastService {
readonly toasts = signal<Toast[]>([]);
show(message: string, type: Toast['type'] = 'info', duration = 4000) {
const id = (globalThis.crypto?.randomUUID?.() ?? `${Date.now()}_${Math.random().toString(36).slice(2)}`);
const now = globalThis.performance?.now?.() ?? Date.now();
const toast: Toast = { id, type, message, duration, startedAt: now, progress: 1 };
// empile (on rendra en reverse pour bas->haut)
this.toasts.update(list => [toast, ...list]);
// boucle rAF pour progression
let raf = 0 as any;
const tick = () => {
const list = this.toasts();
const t = list.find(x => x.id === id);
if (!t) return cancelAnimationFrame(raf);
const nowTick = globalThis.performance?.now?.() ?? Date.now();
const elapsed = nowTick - t.startedAt;
const remaining = Math.max(0, t.duration - elapsed);
const progress = t.duration > 0 ? remaining / t.duration : 0;
this.toasts.update(arr => arr.map(it => it.id === id ? { ...it, progress } : it));
if (remaining <= 0) {
this.dismiss(id);
} else {
raf = requestAnimationFrame(tick);
}
};
raf = requestAnimationFrame(tick);
return id;
}
dismiss(id: string) {
// transition de sortie
this.toasts.update(list => list.map(t => t.id === id ? { ...t, closing: true } : t));
setTimeout(() => {
this.toasts.update(list => list.filter(t => t.id !== id));
}, 360);
}
clearAll() {
const ids = this.toasts().map(t => t.id);
ids.forEach(id => this.dismiss(id));
}
// Helpers de compatibilité
info(message: string, duration = 3500) { return this.show(message, 'info', duration); }
success(message: string, duration = 3000) { return this.show(message, 'success', duration); }
warning(message: string, duration = 5000) { return this.show(message, 'warning', duration); }
error(message: string, duration = 6000) { return this.show(message, 'error', duration); }
}

View File

@ -0,0 +1,7 @@
export function uid(prefix = 't'): string {
return `${prefix}_${Math.random().toString(36).slice(2, 8)}${Date.now().toString(36).slice(-4)}`;
}
export function dedupeKey(type: string | undefined, title: string, description?: string): string {
return `${type || 'info'}|${title}|${description || ''}`;
}

View File

@ -16,6 +16,9 @@ import { Note } from '../../../types';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { NotePreviewService, PreviewData } from '../../../services/note-preview.service';
import { ClipboardService } from '../../../app/shared/services/clipboard.service';
import { ToastService } from '../../../app/shared/toast/toast.service';
import { TagsEditorComponent } from '../../../app/shared/tags-editor/tags-editor.component';
import { VaultService } from '../../../services/vault.service';
import { Subscription } from 'rxjs';
import mermaid from 'mermaid';
@ -62,32 +65,28 @@ interface MetadataEntry {
@Component({
selector: 'app-note-viewer',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, TagsEditorComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
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="sr-only" role="status" aria-live="polite">{{ copyStatus() }}</div>
<!-- Compact Top Bar -->
<div class="flex items-center justify-between gap-2 px-2 py-1 mb-2 text-text-muted text-xs">
<div class="flex items-center justify-between gap-2 pl-1 pr-2 py-1 mb-2 text-text-muted text-xs">
<div class="flex items-center gap-1">
<!-- Directory clickable -->
<button type="button" class="note-toolbar-icon" (click)="directoryClicked.emit(getDirectoryFromPath(note().filePath))" title="Open directory">🗂 {{ note().filePath }}</button>
<button type="button" class="note-toolbar-icon" (click)="copyPath()" aria-label="Copier le chemin" title="Copier le chemin">
<button type="button" class="note-toolbar-icon note-toolbar-path hidden lg:inline-flex" (click)="directoryClicked.emit(getDirectoryFromPath(note().filePath))" title="Open directory">🗂 {{ note().filePath }}</button>
<button type="button" class="note-toolbar-icon hidden lg:inline-flex" (click)="copyPath()" aria-label="Copier le chemin" title="Copier le chemin">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16V6a2 2 0 0 1 2-2h10"/></svg>
</button>
<!-- Tags (discreet) -->
@if (note().tags.length > 0) {
<div class="hidden md:flex items-center gap-1 not-prose">
@for (tag of note().tags; track tag) {
<button type="button" class="md-tag-badge" [ngClass]="tagColorClass(tag)" data-origin="header" [attr.data-tag]="tag" (click)="tagClicked.emit(tag)">
{{ tag }}
</button>
}
</div>
}
<button type="button" class="chip" (click)="addTagRequested.emit()" title="Add tag">+ tag</button>
<!-- Tags editor (read-only -> edit on click) -->
<app-tags-editor class="hidden md:flex ml-3"
[noteId]="note().id"
[tags]="note().tags"
(tagsChange)="onTagsChange($event)"
(commit)="commitTagsNow()"
></app-tags-editor>
</div>
<div class="flex items-center gap-1">
@ -322,6 +321,8 @@ export class NoteViewerComponent implements OnDestroy {
private readonly sanitizer = inject(DomSanitizer);
private readonly previewService = inject(NotePreviewService);
private readonly clipboard = inject(ClipboardService);
private readonly toast = inject(ToastService);
private readonly vault = inject(VaultService);
private readonly tagPaletteSize = 12;
private readonly tagColorCache = new Map<string, number>();
private readonly copyFeedbackTimers = new Map<HTMLElement, number>();
@ -457,6 +458,75 @@ export class NoteViewerComponent implements OnDestroy {
});
}
private tagsSaveTimer: number | null = null;
private pendingTags: string[] | null = null;
onTagsChange(next: string[]): void {
this.pendingTags = next.slice();
if (this.tagsSaveTimer !== null) {
window.clearTimeout(this.tagsSaveTimer);
this.tagsSaveTimer = null;
}
this.tagsSaveTimer = window.setTimeout(() => this.commitTagsNow(), 1000);
}
async commitTagsNow(): Promise<void> {
if (!this.pendingTags) return;
const note = this.note();
if (!note) return;
const tags = this.pendingTags.slice().map(t => `${t}`.trim()).filter(Boolean);
this.pendingTags = null;
try {
const ok = await this.vault.updateNoteTags(note.id, tags);
if (ok) {
this.toast.success('Tags enregistrés');
} else {
this.toast.error('Échec denregistrement des tags');
}
} catch {
this.toast.error('Échec denregistrement des tags');
}
}
async copyPath(): Promise<void> {
const path = this.note()?.filePath ?? '';
try {
const ok = await this.clipboard.write(path);
if (ok) {
this.toast.success(path ? `Path copié — « ${path} »` : 'Path copié');
this.copyStatus.set('Path copié');
} else {
this.toast.error('Impossible de copier le path. Vérifiez les permissions du navigateur.');
this.copyStatus.set('Échec de la copie du path');
}
} catch {
this.toast.error('Impossible de copier le path. Vérifiez les permissions du navigateur.');
this.copyStatus.set('Échec de la copie du path');
}
}
async copyMarkdown(): Promise<void> {
const md = this.note()?.rawContent ?? this.note()?.content ?? '';
if (!md.trim()) {
this.toast.info('Document vide — Aucun contenu Markdown à copier.');
this.copyStatus.set('Document vide, rien à copier');
return;
}
try {
const ok = await this.clipboard.write(md);
if (ok) {
this.toast.success('Markdown copié — Le contenu complet est dans le presse-papiers.');
this.copyStatus.set('Markdown copié');
} else {
this.toast.error('Échec de la copie — Permission refusée. Essayez via le menu contextuel.');
this.copyStatus.set('Échec de la copie du markdown');
}
} catch {
this.toast.error('Échec de la copie — Permission refusée. Essayez via le menu contextuel.');
this.copyStatus.set('Échec de la copie du markdown');
}
}
toggleMenu(): void {
this.menuOpen.update(v => !v);
}
@ -1133,18 +1203,4 @@ export class NoteViewerComponent implements OnDestroy {
if (idx <= 0) return p.replace(/\\/g, '/');
return p.slice(0, idx).replace(/\\/g, '/');
}
async copyPath(): Promise<void> {
const path = this.note()?.originalPath || this.note()?.filePath || '';
const ok = await this.clipboard.write(path);
this.copyStatus.set(ok ? 'Copié !' : 'Échec de la copie');
setTimeout(() => this.copyStatus.set(''), 1500);
}
async copyMarkdown(): Promise<void> {
const raw = this.note()?.rawContent || '';
const ok = await this.clipboard.write(raw);
this.copyStatus.set(ok ? 'Copié !' : 'Échec de la copie');
setTimeout(() => this.copyStatus.set(''), 1500);
}
}

View File

@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http';
import { Note, VaultNode, GraphData, TagInfo, VaultFolder, FileMetadata } from '../types';
import { VaultEventsService, VaultEventPayload } from './vault-events.service';
import { Subscription, firstValueFrom } from 'rxjs';
import { rewriteTagsFrontmatter } from '../app/shared/markdown/markdown-frontmatter.util';
interface VaultApiNote {
id: string;
@ -727,4 +728,66 @@ export class VaultService implements OnDestroy {
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase());
}
/**
* Update tags for a given note id and persist to the underlying Markdown file.
* Automatically refreshes the in-memory note and emits to listeners.
*/
async updateNoteTags(noteId: string, tags: string[]): Promise<boolean> {
const note = this.getNoteById(noteId);
if (!note || !note.filePath) return false;
const currentRaw = note.rawContent ?? this.recomposeMarkdownFromNote(note);
const nextRaw = rewriteTagsFrontmatter(currentRaw, tags);
const ok = await this.saveMarkdown(note.filePath, nextRaw);
if (!ok) return false;
// Update local cache
const updated: Note = {
...note,
rawContent: nextRaw,
tags: Array.from(new Set((tags || []).map(t => `${t}`.trim()).filter(Boolean))),
// Keep frontmatter reference; caller can reparse if needed later
} as Note;
const mapCopy = new Map(this.notesMap());
mapCopy.set(updated.id, updated);
this.notesMap.set(mapCopy);
return true;
}
/** PUT the markdown file content via backend API */
private async saveMarkdown(filePath: string, content: string): Promise<boolean> {
try {
const url = `/api/files?path=${encodeURIComponent(filePath)}`;
await firstValueFrom(this.http.put(url, content, { headers: { 'Content-Type': 'text/markdown' } }));
return true;
} catch (e) {
console.error('[VaultService] saveMarkdown failed', e);
return false;
}
}
/** Fallback to rebuild markdown when rawContent is missing */
private recomposeMarkdownFromNote(note: Note): string {
// Minimal recomposition: frontmatter from existing parsed frontmatter, then body from note.content
try {
const fm = note.frontmatter || {};
const lines: string[] = ['---'];
for (const [k, v] of Object.entries(fm)) {
if (k === 'tags') continue; // will be handled by rewriteTagsFrontmatter
if (Array.isArray(v)) {
lines.push(`${k}:`);
for (const item of v) lines.push(` - ${item}`);
} else {
lines.push(`${k}: ${v}`);
}
}
lines.push('---', (note.content ?? '').replace(/\r\n?/g, '\n'));
return lines.join('\n');
} catch {
return note.content ?? '';
}
}
}

View File

@ -151,6 +151,12 @@
font-size: 1.5rem;
}
.note-toolbar-path {
margin-left: 0;
font-size: 0.95rem;
padding-inline: 6px;
}
.note-toolbar-icon:hover {
color: var(--text-main);
background-color: color-mix(in srgb, var(--bg-muted) 70%, transparent);