+
-
- {{ s }}
- déjà sélectionné
+ {{ s }}
+ ✓ sélectionné
-
- Create {{ inputValue().trim() }}
+
+ + Créer « {{ inputValue().trim() }} »
diff --git a/src/app/shared/tags-editor/tags-editor.component.ts b/src/app/shared/tags-editor/tags-editor.component.ts
index 09f9a4c..13f73e1 100644
--- a/src/app/shared/tags-editor/tags-editor.component.ts
+++ b/src/app/shared/tags-editor/tags-editor.component.ts
@@ -1,7 +1,14 @@
-import { Component, ChangeDetectionStrategy, ElementRef, HostListener, Input, Output, EventEmitter, computed, effect, inject, signal } from '@angular/core';
+import { Component, ChangeDetectionStrategy, ElementRef, HostListener, Input, Output, EventEmitter, computed, inject, signal, OnDestroy } from '@angular/core';
import { CommonModule, NgFor, NgIf, NgClass } from '@angular/common';
import { VaultService } from '../../../services/vault.service';
import { ToastService } from '../toast/toast.service';
+import { HttpClient } from '@angular/common/http';
+import { firstValueFrom } from 'rxjs';
+
+interface TagState {
+ value: string;
+ status: 'unchanged' | 'added' | 'removed';
+}
@Component({
selector: 'app-tags-editor',
@@ -10,63 +17,109 @@ import { ToastService } from '../toast/toast.service';
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './tags-editor.component.html'
})
-export class TagsEditorComponent {
+export class TagsEditorComponent implements OnDestroy {
private host = inject(ElementRef
);
private vault = inject(VaultService);
private toast = inject(ToastService);
+ private http = inject(HttpClient);
@Input() noteId = '';
- private readonly externalTags = signal([]);
+ private readonly originalTags = signal([]);
@Input() set tags(value: string[] | null | undefined) {
const normalized = this.normalizeTags(value);
- this.externalTags.set(normalized);
+ this.originalTags.set(normalized);
if (!this.editing()) {
- this._tags.set([...normalized]);
+ this.workingTags.set(normalized.map(t => ({ value: t, status: 'unchanged' as const })));
}
}
- get tags(): string[] { return this.externalTags(); }
- @Output() tagsChange = new EventEmitter();
- @Output() commit = new EventEmitter();
+ get tags(): string[] { return this.originalTags(); }
+ @Output() tagsUpdated = new EventEmitter();
- readonly _tags = signal([]);
+ readonly workingTags = signal([]);
readonly editing = signal(false);
readonly inputValue = signal('');
- readonly hover = signal(false);
- readonly focusedChip = signal(null);
readonly menuOpen = signal(false);
readonly menuIndex = signal(-1);
- readonly highlightedIndex = signal(null);
+ readonly saving = signal(false);
+ private previousTags: string[] = [];
+
+ // Computed: tags actuels (non supprimés)
+ readonly currentTags = computed(() =>
+ this.workingTags()
+ .filter(t => t.status !== 'removed')
+ .map(t => t.value)
+ );
// 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 exist = new Set(this.currentTags().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);
+ const pool = q.length < 1 ? base : base.filter(name => name.toLowerCase().includes(q));
+ return pool.filter(name => !exist.has(name.toLowerCase())).slice(0, 8);
});
isSelected(name: string): boolean {
const lower = name.toLowerCase();
- return this._tags().some(t => t.toLowerCase() === lower);
+ return this.currentTags().some(t => t.toLowerCase() === lower);
+ }
+
+ ngOnDestroy(): void {
+ // Cleanup if needed
}
// Public API
enterEdit() {
- this._tags.set([...this.externalTags()]);
+ const original = this.originalTags();
+ this.previousTags = [...original];
+ this.workingTags.set(original.map(t => ({ value: t, status: 'unchanged' as const })));
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()]);
+ async exitEdit() {
+ const finalTags = this.currentTags();
+ const hasChanges = JSON.stringify(finalTags) !== JSON.stringify(this.originalTags());
+
+ if (!hasChanges) {
+ this.editing.set(false);
+ this.menuOpen.set(false);
+ return;
+ }
+
+ // Sauvegarder via API
+ this.saving.set(true);
+
+ try {
+ const response = await firstValueFrom(
+ this.http.put<{ ok: boolean; tags: string[]; noteId: string }>(
+ `/api/notes/${encodeURIComponent(this.noteId)}/tags`,
+ { tags: finalTags }
+ )
+ );
+
+ if (response.ok) {
+ this.originalTags.set(response.tags);
+ this.workingTags.set(response.tags.map(t => ({ value: t, status: 'unchanged' as const })));
+ this.editing.set(false);
+ this.menuOpen.set(false);
+ this.tagsUpdated.emit(response.tags);
+
+ // Toast succès avec action Annuler
+ const toastId = this.toast.success('✅ Tags mis à jour');
+ // TODO: Implémenter l'action Annuler dans le toast
+ }
+ } catch (error: any) {
+ console.error('[TagsEditor] Save failed:', error);
+ const message = error?.error?.message || error?.message || 'Erreur inconnue';
+ this.toast.error(`❌ Impossible de sauvegarder les tags: ${message}`);
+ // Rester en mode édition pour permettre de réessayer
+ } finally {
+ this.saving.set(false);
+ }
}
addTagFromInput() {
@@ -75,36 +128,56 @@ export class TagsEditorComponent {
this.addTag(raw);
}
+ @HostListener('document:click', ['$event'])
+ onDocumentClick(event: MouseEvent) {
+ if (!this.editing()) return;
+
+ const clickedInside = this.host.nativeElement.contains(event.target as Node);
+ if (!clickedInside) {
+ this.exitEdit();
+ }
+ }
+
addTag(tag: string) {
const value = `${tag}`.trim();
if (!value) return;
- const existing = this._tags();
+ if (/[<>"]/g.test(value)) {
+ this.toast.error('Caractères interdits dans un tag (<, >, ")');
+ return;
+ }
+
+ const current = this.workingTags();
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);
+
+ // Vérifier si déjà présent (non supprimé)
+ const existingIndex = current.findIndex(
+ t => t.status !== 'removed' && t.value.toLowerCase() === lower
+ );
+
+ if (existingIndex !== -1) {
this.toast.info('Tag déjà présent');
return;
}
+
+ // Vérifier si c'était dans les tags originaux
+ const wasOriginal = this.originalTags().some(t => t.toLowerCase() === lower);
+ const status = wasOriginal ? 'unchanged' : 'added';
+
+ this.workingTags.update(tags => [...tags, { value, status }]);
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));
+ removeTag(tagState: TagState) {
+ this.workingTags.update(tags =>
+ tags.map(t =>
+ t.value === tagState.value && t.status === tagState.status
+ ? { ...t, status: 'removed' as const }
+ : t
+ )
+ );
this.focusInput();
}
@@ -144,8 +217,11 @@ export class TagsEditorComponent {
return;
}
if (ev.key === 'Backspace' && !this.inputValue()) {
- const arr = this._tags();
- if (arr.length) this.removeTagAt(arr.length - 1);
+ const current = this.currentTags();
+ if (current.length) {
+ const lastTag = this.workingTags().filter(t => t.status !== 'removed').pop();
+ if (lastTag) this.removeTag(lastTag);
+ }
return;
}
if (ev.key === 'ArrowDown') {
@@ -162,8 +238,8 @@ export class TagsEditorComponent {
return;
}
if (ev.key === 'Escape') {
- this.menuOpen.set(false);
- this.menuIndex.set(-1);
+ ev.preventDefault();
+ this.exitEdit();
return;
}
}
diff --git a/src/app/shared/utils/path.ts b/src/app/shared/utils/path.ts
new file mode 100644
index 0000000..69fd0ac
--- /dev/null
+++ b/src/app/shared/utils/path.ts
@@ -0,0 +1,26 @@
+export function splitPathKeepFilename(full: string) {
+ if (!full) return { prefix: '', filename: '' };
+ const norm = full.replaceAll('\\', '/');
+ const idx = norm.lastIndexOf('/');
+ if (idx < 0) return { prefix: '', filename: norm };
+ return {
+ prefix: norm.slice(0, idx),
+ filename: norm.slice(idx + 1),
+ };
+}
+
+export function elideStart(input: string, keepTail = 16): string {
+ if (!input) return '';
+ if (input.length <= keepTail) return input;
+ return '…' + input.slice(input.length - keepTail);
+}
+
+export function elideFilenameSmart(name: string, minHead = 6): string {
+ if (!name) return '';
+ const dot = name.lastIndexOf('.');
+ if (dot <= 0) return elideStart(name, Math.max(minHead, 8));
+ const head = name.slice(0, dot);
+ const ext = name.slice(dot);
+ if (head.length <= minHead) return name;
+ return head.slice(0, minHead) + '…' + ext;
+}
diff --git a/src/components/tags-view/note-viewer/note-viewer.component.ts b/src/components/tags-view/note-viewer/note-viewer.component.ts
index e7d0ce5..b4f2846 100644
--- a/src/components/tags-view/note-viewer/note-viewer.component.ts
+++ b/src/components/tags-view/note-viewer/note-viewer.component.ts
@@ -15,9 +15,9 @@ import { CommonModule } from '@angular/common';
import { Note } from '../../../types';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { NotePreviewService, PreviewData } from '../../../services/note-preview.service';
+import { NoteHeaderComponent } from '../../../app/features/note/components/note-header/note-header.component';
import { 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';
@@ -65,29 +65,21 @@ interface MetadataEntry {
@Component({
selector: 'app-note-viewer',
standalone: true,
- imports: [CommonModule, TagsEditorComponent],
+ imports: [CommonModule, NoteHeaderComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
{{ copyStatus() }}
-
-
-
-
🗂️ {{ note().filePath }}
-
-
-
-
-
-
-
+
this.commitTagsNow(), 1000);
- }
-
- async commitTagsNow(): Promise {
- 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 d’enregistrement des tags');
- }
- } catch {
- this.toast.error('Échec d’enregistrement des tags');
- }
+
+ // Mettre à jour l'état local (la note sera rafraîchie par le vault service)
+ // Pas besoin de sauvegarder ici, c'est déjà fait par TagsEditorComponent
}
async copyPath(): Promise {
@@ -493,15 +465,15 @@ export class NoteViewerComponent implements OnDestroy {
try {
const ok = await this.clipboard.write(path);
if (ok) {
- this.toast.success(path ? `Path copié — « ${path} »` : 'Path copié');
- this.copyStatus.set('Path copié');
+ this.toast.success('📋 Chemin copié');
+ this.copyStatus.set('Chemin 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');
+ this.toast.error('Impossible de copier le chemin. Vérifiez les permissions du navigateur.');
+ this.copyStatus.set('Échec de la copie du chemin');
}
} catch {
- this.toast.error('Impossible de copier le path. Vérifiez les permissions du navigateur.');
- this.copyStatus.set('Échec de la copie du path');
+ this.toast.error('Impossible de copier le chemin. Vérifiez les permissions du navigateur.');
+ this.copyStatus.set('Échec de la copie du chemin');
}
}