feat: add tag editor API endpoint and improve tag editing UI
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user