feat: add tag editor API endpoint and improve tag editing UI

This commit is contained in:
2025-10-17 09:14:01 -04:00
parent 4dbcf8ad81
commit e2775a3d43
12 changed files with 1133 additions and 150 deletions
@@ -0,0 +1,27 @@
<header class="note-header flex flex-col gap-2 min-w-0">
<div class="flex items-center gap-2">
<button type="button"
class="flex-shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-slate-900 dark:hover:text-slate-200 transition-all duration-150 shadow-sm"
aria-label="Copier le chemin"
title="Copier le chemin"
(click)="copyRequested.emit()">
<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>
<div class="path-wrap flex items-center gap-1 min-w-0 flex-1 cursor-pointer" (click)="onPathClick()" (contextmenu)="onPathContextMenu($event)" role="button" tabindex="0" [attr.aria-label]="'Ouvrir le dossier ' + pathParts.filename">
<span class="path-prefix shrink min-w-0 overflow-hidden whitespace-nowrap text-ellipsis" [title]="fullPath">
{{ pathParts.prefix }}
</span>
<span class="path-sep" *ngIf="pathParts.prefix">/</span>
<span class="path-filename whitespace-nowrap" [title]="pathParts.filename">
{{ pathParts.filename }}
</span>
</div>
</div>
<app-tags-editor class="note-header__tags"
[noteId]="noteId"
[tags]="tags"
(tagsUpdated)="tagsChange.emit($event)"
></app-tags-editor>
</header>
@@ -0,0 +1,36 @@
.note-header {
width: 100%;
min-width: 0;
overflow: visible;
}
.path-wrap {
min-width: 0;
overflow: hidden;
}
.path-prefix {
color: rgb(148 163 184);
font-size: 0.85rem;
}
.path-filename {
font-weight: 600;
font-size: 0.95rem;
}
.note-header__action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 0.4rem;
background: transparent;
color: inherit;
transition: background-color 120ms ease, color 120ms ease;
}
.note-header__action:hover {
background-color: rgba(148, 163, 184, 0.15);
}
@@ -0,0 +1,129 @@
import { AfterViewInit, Component, ElementRef, Input, OnDestroy, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { debounceTime, Subject } from 'rxjs';
import { splitPathKeepFilename } from '../../../../shared/utils/path';
import { TagsEditorComponent } from '../../../../shared/tags-editor/tags-editor.component';
@Component({
selector: 'app-note-header',
standalone: true,
imports: [CommonModule, TagsEditorComponent],
templateUrl: './note-header.component.html',
styleUrls: ['./note-header.component.scss']
})
export class NoteHeaderComponent implements AfterViewInit, OnDestroy {
@Input() fullPath = '';
@Input() noteId = '';
@Input() tags: string[] = [];
@Output() openDirectory = new EventEmitter<void>();
@Output() copyRequested = new EventEmitter<void>();
@Output() tagsChange = new EventEmitter<string[]>();
pathParts: { prefix: string; filename: string } = { prefix: '', filename: '' };
private ro?: ResizeObserver;
private resize$ = new Subject<void>();
constructor(private host: ElementRef<HTMLElement>) {}
ngAfterViewInit(): void {
this.pathParts = splitPathKeepFilename(this.fullPath);
this.ro = new ResizeObserver(() => this.resize$.next());
this.ro.observe(this.host.nativeElement);
this.resize$.pipe(debounceTime(50)).subscribe(() => {
this.applyProgressiveCollapse();
this.fitPath();
});
queueMicrotask(() => {
this.applyProgressiveCollapse();
this.fitPath();
});
}
ngOnDestroy(): void {
this.ro?.disconnect();
}
private applyProgressiveCollapse(): void {
const root = this.host.nativeElement;
const extras = root.querySelector('.note-header__extras') as HTMLElement | null;
if (!extras) return;
extras.querySelectorAll<HTMLElement>('[data-collapse-priority]').forEach(el => {
el.style.display = '';
});
const overflowing = () => root.scrollWidth > root.clientWidth + 2;
const candidates = Array.from(
extras.querySelectorAll<HTMLElement>('[data-collapse-priority]')
).sort((a, b) =>
(parseInt(b.dataset.collapsePriority || '0', 10)) -
(parseInt(a.dataset.collapsePriority || '0', 10))
);
for (const el of candidates) {
if (!overflowing()) break;
el.style.display = 'none';
}
}
private fitPath(): void {
const root = this.host.nativeElement;
const prefix = root.querySelector('.path-prefix') as HTMLElement | null;
const filename = root.querySelector('.path-filename') as HTMLElement | null;
if (!prefix || !filename) return;
const base = splitPathKeepFilename(this.fullPath);
let prefixText = base.prefix;
let fileText = base.filename;
prefix.textContent = prefixText;
filename.textContent = fileText;
const fits = () => root.scrollWidth <= root.clientWidth + 2;
while (!fits() && prefixText.length > 0) {
const slashIdxs = ['/', '\\']
.map(s => prefixText.indexOf(s))
.filter(i => i >= 0);
const slashIdx = slashIdxs.length ? Math.min(...slashIdxs) : -1;
if (slashIdx >= 0) {
prefixText = prefixText.slice(slashIdx + 1);
} else {
prefixText = prefixText.slice(2);
}
const cleaned = prefixText.replace(/^[/\\]+/, '');
prefix.textContent = cleaned ? `…/${cleaned}` : '';
if (fits()) return;
}
if (!fits()) {
const { head, ext } = splitName(fileText);
let trimmed = head;
while (!fits() && trimmed.length > 4) {
trimmed = trimmed.slice(0, trimmed.length - 2);
filename.textContent = trimmed + '…' + ext;
}
}
function splitName(name: string) {
const dot = name.lastIndexOf('.');
if (dot <= 0) return { head: name, ext: '' };
return { head: name.slice(0, dot), ext: name.slice(dot) };
}
}
onPathClick(): void {
this.openDirectory.emit();
}
onPathContextMenu(event: MouseEvent): void {
event.preventDefault();
this.copyRequested.emit();
}
}
@@ -1,29 +1,90 @@
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';
};
/**
* Normalise un tag : trim, normalise les espaces, lowercase pour comparaison
*/
function normalizeTag(tag: string): string {
return tag.trim().replace(/\s+/g, ' ');
}
if (!fmMatch) {
const tagsBlock = buildTagsBlock(tags);
return `---\n${tagsBlock}---\n` + content;
/**
* Déduplique les tags (case-insensitive) en préservant la première occurrence
*/
function deduplicateTags(tags: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const tag of tags) {
const normalized = normalizeTag(tag);
if (!normalized) continue;
const lower = normalized.toLowerCase();
if (!seen.has(lower)) {
seen.add(lower);
result.push(normalized);
}
}
return result;
}
/**
* Réécrit le front-matter YAML d'un fichier Markdown avec les tags fournis.
*
* Règles métier :
* - Crée la section --- ... --- si absente
* - Normalise et déduplique les tags (case-insensitive)
* - Si liste vide, supprime la clé tags: (ne laisse pas tags: [])
* - Aucune ligne vide dans le front-matter résultant
* - Préserve l'ordre et le format des autres propriétés
*/
export function rewriteTagsFrontmatter(rawMarkdown: string, tags: string[]): string {
// Normaliser le contenu (BOM, line endings)
const content = rawMarkdown.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
// Détecter le front-matter existant
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)/);
// Normaliser et dédupliquer les tags
const cleanTags = deduplicateTags(tags);
// Construire le bloc tags (format YAML liste)
const buildTagsBlock = (tagList: string[]): string => {
if (tagList.length === 0) return ''; // Ne pas créer de clé tags si vide
return 'tags:\n' + tagList.map(t => ` - ${t}`).join('\n');
};
const newTagsBlock = buildTagsBlock(cleanTags);
// Cas 1 : Pas de front-matter existant
if (!fmMatch) {
if (cleanTags.length === 0) {
// Pas de tags, pas de front-matter à créer
return content;
}
// Créer un nouveau front-matter avec les tags
return `---\n${newTagsBlock}\n---\n${content}`;
}
// Cas 2 : Front-matter existant
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');
// Supprimer l'ancien bloc tags (format liste ou inline)
const tagsRe = /(^|\n)tags\s*:[^\n]*(?:\n\s+-\s+[^\n]*)*(?=\n|$)/i;
let updatedFm = fmText.replace(tagsRe, '');
// Nettoyer les lignes vides multiples
updatedFm = updatedFm.replace(/\n{2,}/g, '\n').trim();
// Ajouter le nouveau bloc tags si non vide
if (newTagsBlock) {
updatedFm = updatedFm ? `${updatedFm}\n${newTagsBlock}` : newTagsBlock;
}
// Append tags block at the end of frontmatter
const fmWithTags = fmText.replace(/\n*$/, '\n') + buildTagsBlock(tags);
return `---\n${fmWithTags}---\n${body}`;
// Si le front-matter est vide après suppression, ne pas créer de section vide
if (!updatedFm.trim()) {
return body;
}
// Reconstruire le document
return `---\n${updatedFm}\n---\n${body}`;
}
@@ -1,57 +1,94 @@
<!-- 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>
<div class="not-prose group relative" [class.cursor-pointer]="!editing()" (click)="!editing() && enterEdit()">
<div *ngIf="!editing(); else editTpl" class="flex items-center gap-2.5 text-sm transition-all duration-200 hover:opacity-80">
<span class="text-text-muted inline-flex items-center opacity-60">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" 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 *ngIf="currentTags().length; else emptyTpl">
<div class="flex flex-wrap gap-1.5 items-center">
<ng-container *ngFor="let t of currentTags()">
<span class="inline-flex items-center gap-1 rounded-xl bg-slate-100 dark:bg-slate-800 px-2 py-1 text-xs font-medium text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-700 shadow-sm transition-all duration-150 hover:bg-slate-200 dark:hover:bg-slate-700">
{{ t }}
</span>
</ng-container>
</div>
</ng-container>
<ng-template #emptyTpl>
<span class="text-text-muted text-xs">Ajouter des tags…</span>
<span class="text-slate-500 dark:text-slate-400 text-xs italic">Cliquer pour 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 class="relative w-full animate-in fade-in duration-200">
<!-- Carte d'édition avec backdrop blur -->
<div class="rounded-2xl p-3 sm:p-4 shadow-md border border-slate-200 dark:border-slate-700 bg-white/60 dark:bg-slate-900/50 backdrop-blur">
<div class="flex items-start gap-2">
<!-- Zone des chips + input -->
<div class="flex-1 flex flex-wrap items-center gap-1.5 rounded-xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 px-3 py-2 focus-within:ring-2 focus-within:ring-sky-400 dark:focus-within:ring-sky-500 transition-all duration-200">
<ng-container *ngFor="let tagState of workingTags()">
<span *ngIf="tagState.status !== 'removed'"
class="inline-flex items-center gap-1 rounded-xl px-2 py-1 text-xs font-medium border shadow-sm transition-all duration-150"
[ngClass]="{
'border-emerald-300/70 bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300': tagState.status === 'added',
'border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-700 text-slate-700 dark:text-slate-300': tagState.status === 'unchanged'
}">
<span *ngIf="tagState.status === 'added'" class="text-emerald-600 dark:text-emerald-400 font-bold">+</span>
{{ tagState.value }}
<button type="button"
class="ml-0.5 opacity-60 hover:opacity-100 hover:text-rose-600 dark:hover:text-rose-400 transition-all"
(click)="removeTag(tagState)"
aria-label="Supprimer">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" 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-[10ch] flex-1 bg-transparent text-sm outline-none placeholder:text-slate-400 dark:placeholder:text-slate-500 text-slate-900 dark:text-slate-100"
[value]="inputValue()"
(input)="onInput($event)"
(keydown)="onInputKeydown($event)"
placeholder="Taper pour rechercher..."
[disabled]="saving()" />
</div>
<!-- Bouton Fermer l'édition -->
<button type="button"
class="flex-shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-slate-900 dark:hover:text-slate-200 transition-all duration-150 shadow-sm"
(click)="exitEdit()"
[disabled]="saving()"
aria-label="Fermer l'édition"
title="Fermer l'édition">
<svg *ngIf="!saving()" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
<svg *ngIf="saving()" class="animate-spin" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
</button>
</div>
</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">
<div *ngIf="menuOpen()" class="absolute left-0 top-[calc(100%+8px)] z-50 max-h-[280px] w-full min-w-[280px] overflow-auto rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 backdrop-blur-sm shadow-2xl animate-in fade-in slide-in-from-top-2 duration-200">
<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) }"
<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-all duration-150 hover:bg-slate-100 dark:hover:bg-slate-800 hover:pl-4 text-slate-900 dark:text-slate-100"
[ngClass]="{
'bg-sky-50 dark:bg-sky-900/20 border-l-2 border-sky-500': menuIndex() === i,
'opacity-40 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>
<span class="font-medium">{{ s }}</span>
<span *ngIf="isSelected(s)" class="float-right text-xs text-slate-400 dark:text-slate-500 italic">✓ 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 type="button"
class="block w-full text-left px-3 py-2 text-sm text-sky-600 dark:text-sky-400 hover:bg-slate-100 dark:hover:bg-slate-800 font-medium"
(mousedown)="$event.preventDefault()"
(click)="pickSuggestion(-1)">
<span class="opacity-60">+</span> Créer « {{ inputValue().trim() }} »
</button>
</ng-template>
</div>
@@ -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<HTMLElement>);
private vault = inject(VaultService);
private toast = inject(ToastService);
private http = inject(HttpClient);
@Input() noteId = '';
private readonly externalTags = signal<string[]>([]);
private readonly originalTags = signal<string[]>([]);
@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<string[]>();
@Output() commit = new EventEmitter<void>();
get tags(): string[] { return this.originalTags(); }
@Output() tagsUpdated = new EventEmitter<string[]>();
readonly _tags = signal<string[]>([]);
readonly workingTags = signal<TagState[]>([]);
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);
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;
}
}
+26
View File
@@ -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;
}