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();
}
}