ObsiViewer/src/app/features/note/components/note-header/note-header.component.ts

216 lines
6.8 KiB
TypeScript

import { AfterViewInit, Component, ElementRef, Input, OnDestroy, Output, EventEmitter, ViewContainerRef, inject, OnChanges, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { debounceTime, Subject } from 'rxjs';
import { splitPathKeepFilename } from '../../../../shared/utils/path';
import { TagManagerComponent } from '../../../../shared/tags/tag-manager/tag-manager.component';
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import { PropertiesPopoverComponent } from '../properties-popover/properties-popover.component';
import { FrontmatterPropertiesService } from '../../shared/frontmatter-properties.service';
import { VaultService } from '../../../../../services/vault.service';
@Component({
selector: 'app-note-header',
standalone: true,
imports: [CommonModule, TagManagerComponent],
templateUrl: './note-header.component.html',
styleUrls: ['./note-header.component.scss']
})
export class NoteHeaderComponent implements AfterViewInit, OnDestroy, OnChanges {
@Input() fullPath = '';
@Input() noteId = '';
@Input() tags: string[] = [];
@Output() openDirectory = new EventEmitter<void>();
@Output() copyRequested = new EventEmitter<void>();
@Output() tagsChange = new EventEmitter<string[]>();
@Output() tagSelected = new EventEmitter<string>();
pathParts: { prefix: string; filename: string } = { prefix: '', filename: '' };
private ro?: ResizeObserver;
private resize$ = new Subject<void>();
// Properties popover
popoverOpen = false;
private overlayRef?: OverlayRef;
private closeTimer?: any;
private overlay = inject(Overlay);
private vcr = inject(ViewContainerRef);
private frontmatterService = inject(FrontmatterPropertiesService);
private vaultService = inject(VaultService);
constructor(private host: ElementRef<HTMLElement>) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes['fullPath']) {
this.pathParts = splitPathKeepFilename(this.fullPath);
// Also update the path display if component is already initialized
if (this.ro) {
this.fitPath();
}
}
}
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();
this.closePopover();
}
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();
}
openPopover(origin: HTMLElement): void {
clearTimeout(this.closeTimer);
if (this.overlayRef && this.popoverOpen) return;
const positionStrategy = this.overlay.position()
.flexibleConnectedTo(origin)
.withPositions([
{ originX: 'end', originY: 'center', overlayX: 'start', overlayY: 'center', offsetX: 8 },
{ originX: 'center', originY: 'top', overlayX: 'center', overlayY: 'bottom', offsetY: -8 },
])
.withPush(true);
this.overlayRef = this.overlay.create({
positionStrategy,
hasBackdrop: false,
scrollStrategy: this.overlay.scrollStrategies.reposition(),
});
const portal = new ComponentPortal(PropertiesPopoverComponent, this.vcr);
const compRef = this.overlayRef.attach(portal);
// Get note and properties
const note = this.vaultService.getNoteById(this.noteId);
const props = this.frontmatterService.get(note);
compRef.instance.props = props;
compRef.instance.noteId = this.noteId;
compRef.instance.requestClose.subscribe(() => this.scheduleClose());
compRef.instance.cancelClose.subscribe(() => clearTimeout(this.closeTimer));
this.overlayRef.outsidePointerEvents().subscribe(() => this.closePopover());
this.overlayRef.detachments().subscribe(() => {
this.popoverOpen = false;
});
this.popoverOpen = true;
}
scheduleClose(): void {
clearTimeout(this.closeTimer);
this.closeTimer = setTimeout(() => this.closePopover(), 150);
}
togglePopover(origin: HTMLElement): void {
if (this.popoverOpen) {
this.closePopover();
} else {
this.openPopover(origin);
}
}
closePopover(): void {
clearTimeout(this.closeTimer);
if (this.overlayRef) {
this.overlayRef.dispose();
this.overlayRef = undefined;
}
this.popoverOpen = false;
}
}