216 lines
6.8 KiB
TypeScript
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;
|
|
}
|
|
}
|