ObsiViewer/src/app.component.ts

548 lines
17 KiB
TypeScript

import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, ElementRef, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
// Services
import { VaultService } from './services/vault.service';
import { MarkdownService } from './services/markdown.service';
// Components
import { FileExplorerComponent } from './components/file-explorer/file-explorer.component';
import { NoteViewerComponent, WikiLinkActivation } from './components/note-viewer/note-viewer.component';
import { GraphViewComponent } from './components/graph-view/graph-view.component';
import { TagsViewComponent } from './components/tags-view/tags-view.component';
import { MarkdownCalendarComponent } from './components/markdown-calendar/markdown-calendar.component';
// Types
import { FileMetadata, Note, TagInfo, VaultNode } from './types';
interface TocEntry {
level: number;
text: string;
id: string;
}
@Component({
selector: 'app-root',
imports: [
CommonModule,
FormsModule,
FileExplorerComponent,
NoteViewerComponent,
GraphViewComponent,
TagsViewComponent,
MarkdownCalendarComponent,
],
templateUrl: './app.component.simple.html',
styleUrls: ['./app.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements OnDestroy {
private vaultService = inject(VaultService);
private markdownService = inject(MarkdownService);
private elementRef = inject(ElementRef);
// --- State Signals ---
isDarkMode = signal<boolean>(true);
isSidebarOpen = signal<boolean>(true);
isOutlineOpen = signal<boolean>(true);
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar'>('files');
selectedNoteId = signal<string>('');
sidebarSearchTerm = signal<string>('');
tableOfContents = signal<TocEntry[]>([]);
leftSidebarWidth = signal<number>(288);
rightSidebarWidth = signal<number>(288);
readonly LEFT_MIN_WIDTH = 220;
readonly LEFT_MAX_WIDTH = 520;
readonly RIGHT_MIN_WIDTH = 220;
readonly RIGHT_MAX_WIDTH = 520;
private viewportWidth = signal<number>(typeof window !== 'undefined' ? window.innerWidth : 0);
private resizeHandler = () => {
if (typeof window === 'undefined') {
return;
}
this.viewportWidth.set(window.innerWidth);
};
isDesktopView = computed<boolean>(() => this.viewportWidth() >= 1024);
private wasDesktop = false;
calendarResults = signal<FileMetadata[]>([]);
calendarSearchState = signal<'idle' | 'loading' | 'error'>('idle');
calendarSearchError = signal<string | null>(null);
calendarSelectionLabel = signal<string | null>(null);
calendarOverlayVisible = computed<boolean>(() =>
this.calendarSearchState() === 'loading' ||
!!this.calendarSearchError() ||
this.calendarResults().length > 0
);
private calendarSearchTriggered = false;
private pendingWikiNavigation = signal<{ noteId: string; heading?: string; block?: string } | null>(null);
// --- Data Signals ---
fileTree = this.vaultService.fileTree;
graphData = this.vaultService.graphData;
allTags = this.vaultService.tags;
vaultName = this.vaultService.vaultName;
// --- Computed Signals ---
selectedNote = computed<Note | undefined>(() => {
const id = this.selectedNoteId();
return id ? this.vaultService.getNoteById(id) : undefined;
});
renderedNoteContent = computed<string>(() => {
const note = this.selectedNote();
if (!note) return '';
const allNotes = this.vaultService.allNotes();
return this.markdownService.render(note.content, allNotes, note);
});
selectedNoteBreadcrumb = computed<string[]>(() => {
const note = this.selectedNote();
if (!note) {
return [];
}
const vaultTitle = this.vaultName().trim();
const breadcrumb: string[] = [vaultTitle || 'Vault'];
const pathSegments = note.originalPath.split('/').filter(Boolean);
if (pathSegments.length === 0) {
breadcrumb.push(note.fileName.replace(/\.md$/i, ''));
return breadcrumb;
}
const displaySegments = [...pathSegments];
displaySegments[displaySegments.length - 1] = note.fileName.replace(/\.md$/i, '');
return breadcrumb.concat(displaySegments);
});
filteredTags = computed<TagInfo[]>(() => {
const term = this.sidebarSearchTerm().trim().toLowerCase();
const cleanedTerm = term.startsWith('#') ? term.slice(1) : term;
if (!cleanedTerm) return this.allTags();
return this.allTags().filter(tag => tag.name.toLowerCase().includes(cleanedTerm));
});
filteredFileTree = computed<VaultNode[]>(() => {
const term = this.sidebarSearchTerm().trim().toLowerCase();
if (!term || term.startsWith('#')) return this.fileTree();
// Simple flat search for files
return this.fileTree().filter(node => node.type === 'file' && node.name.toLowerCase().includes(term));
});
activeTagFilter = computed<string | null>(() => {
const rawTerm = this.sidebarSearchTerm().trim();
if (!rawTerm.startsWith('#')) {
return null;
}
const tag = rawTerm.slice(1).trim();
return tag ? tag.toLowerCase() : null;
});
searchResults = computed<Note[]>(() => {
const notes = this.vaultService.allNotes();
const tagFilter = this.activeTagFilter();
if (tagFilter) {
return notes.filter(note => note.tags.some(tag => tag.toLowerCase() === tagFilter));
}
const term = this.sidebarSearchTerm().trim().toLowerCase();
if (!term) return [];
const cleanedTerm = term.startsWith('#') ? term.slice(1) : term;
return notes.filter(note =>
note.title.toLowerCase().includes(cleanedTerm) ||
note.content.toLowerCase().includes(cleanedTerm) ||
note.tags.some(tag => tag.toLowerCase().includes(cleanedTerm))
);
});
constructor() {
if (typeof window !== 'undefined') {
window.addEventListener('resize', this.resizeHandler, { passive: true });
}
this.wasDesktop = this.isDesktopView();
if (!this.isDesktopView()) {
this.isSidebarOpen.set(false);
this.isOutlineOpen.set(false);
}
// Effect to update the DOM with the dark class
effect(() => {
if (this.isDarkMode()) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
});
effect(() => {
const isDesktop = this.isDesktopView();
if (isDesktop && !this.wasDesktop) {
this.isSidebarOpen.set(true);
this.isOutlineOpen.set(true);
}
if (!isDesktop && this.wasDesktop) {
this.isSidebarOpen.set(false);
this.isOutlineOpen.set(false);
}
this.wasDesktop = isDesktop;
});
// Effect to generate Table of Contents when the note changes
effect(() => {
const html = this.renderedNoteContent();
if (html && this.selectedNote()) {
this.generateToc(html);
} else {
this.tableOfContents.set([]);
}
});
effect(() => {
const pending = this.pendingWikiNavigation();
const activeNoteId = this.selectedNoteId();
const html = this.renderedNoteContent();
if (!pending || pending.noteId !== activeNoteId || !html) {
return;
}
queueMicrotask(() => {
if (pending.heading) {
this.scrollToHeading(pending.heading);
} else if (pending.block) {
this.scrollToBlock(pending.block);
}
this.pendingWikiNavigation.set(null);
});
});
effect(() => {
if (typeof document === 'undefined') {
return;
}
const vaultTitle = this.vaultName().trim();
document.title = `ObsiWatcher - ${vaultTitle || 'Vault'}`;
});
// Effect to select first available note when vault data loads
effect(() => {
const notes = this.vaultService.allNotes();
const currentId = this.selectedNoteId();
if (!notes.length) {
return;
}
const currentExists = notes.some(note => note.id === currentId);
if (!currentExists) {
const firstNote = notes[0];
this.vaultService.ensureFolderOpen(firstNote.originalPath);
this.selectedNoteId.set(firstNote.id);
}
});
}
ngOnDestroy(): void {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', this.resizeHandler);
}
}
// --- Methods ---
toggleTheme(): void {
this.isDarkMode.update(value => !value);
}
toggleSidebar(): void {
this.isSidebarOpen.update(value => !value);
}
closeSidebar(): void {
this.isSidebarOpen.set(false);
}
toggleSidebarTo(state: boolean): void {
this.isSidebarOpen.set(state);
}
toggleOutline(): void {
this.isOutlineOpen.update(value => !value);
}
closeOutlinePanel(): void {
this.isOutlineOpen.set(false);
}
toggleOutlineTo(state: boolean): void {
this.isOutlineOpen.set(state);
}
isDesktop(): boolean {
return this.isDesktopView();
}
onCalendarResultsChange(files: FileMetadata[]): void {
this.calendarResults.set(files);
if (this.calendarSearchTriggered || files.length > 0 || this.activeView() === 'search') {
this.isSidebarOpen.set(true);
this.activeView.set('search');
this.calendarSearchTriggered = false;
}
}
onCalendarSearchStateChange(state: 'idle' | 'loading' | 'error'): void {
this.calendarSearchState.set(state);
if (state === 'loading') {
this.calendarSearchTriggered = true;
}
}
onCalendarSearchErrorChange(message: string | null): void {
this.calendarSearchError.set(message);
if (message) {
this.isSidebarOpen.set(true);
this.activeView.set('search');
this.calendarSearchTriggered = false;
}
}
onCalendarSelectionSummaryChange(summary: string | null): void {
this.calendarSelectionLabel.set(summary);
}
onCalendarRequestSearchPanel(): void {
if (this.activeView() === 'calendar') {
if (!this.isSidebarOpen()) {
this.isSidebarOpen.set(true);
}
return;
}
this.isSidebarOpen.set(true);
this.activeView.set('search');
}
startLeftResize(event: PointerEvent): void {
if (!this.isSidebarOpen()) {
this.isSidebarOpen.set(true);
}
event.preventDefault();
const handle = event.currentTarget as HTMLElement | null;
handle?.setPointerCapture(event.pointerId);
const startX = event.clientX;
const startWidth = this.leftSidebarWidth();
const moveHandler = (moveEvent: PointerEvent) => {
const delta = moveEvent.clientX - startX;
let newWidth = startWidth + delta;
newWidth = Math.max(this.LEFT_MIN_WIDTH, Math.min(this.LEFT_MAX_WIDTH, newWidth));
this.leftSidebarWidth.set(newWidth);
};
const cleanup = () => {
window.removeEventListener('pointermove', moveHandler);
window.removeEventListener('pointerup', cleanup);
window.removeEventListener('pointercancel', cleanup);
if (handle && handle.hasPointerCapture?.(event.pointerId)) {
handle.releasePointerCapture(event.pointerId);
}
handle?.removeEventListener('lostpointercapture', cleanup);
};
window.addEventListener('pointermove', moveHandler);
window.addEventListener('pointerup', cleanup);
window.addEventListener('pointercancel', cleanup);
handle?.addEventListener('lostpointercapture', cleanup);
}
startRightResize(event: PointerEvent): void {
if (!this.isOutlineOpen()) {
this.isOutlineOpen.set(true);
}
event.preventDefault();
const handle = event.currentTarget as HTMLElement | null;
handle?.setPointerCapture(event.pointerId);
const startX = event.clientX;
const startWidth = this.rightSidebarWidth();
const moveHandler = (moveEvent: PointerEvent) => {
const delta = moveEvent.clientX - startX;
let newWidth = startWidth - delta;
newWidth = Math.max(this.RIGHT_MIN_WIDTH, Math.min(this.RIGHT_MAX_WIDTH, newWidth));
this.rightSidebarWidth.set(newWidth);
};
const cleanup = () => {
window.removeEventListener('pointermove', moveHandler);
window.removeEventListener('pointerup', cleanup);
window.removeEventListener('pointercancel', cleanup);
if (handle && handle.hasPointerCapture?.(event.pointerId)) {
handle.releasePointerCapture(event.pointerId);
}
handle?.removeEventListener('lostpointercapture', cleanup);
};
window.addEventListener('pointermove', moveHandler);
window.addEventListener('pointerup', cleanup);
window.addEventListener('pointercancel', cleanup);
handle?.addEventListener('lostpointercapture', cleanup);
}
setView(view: 'files' | 'graph' | 'tags' | 'search' | 'calendar'): void {
this.activeView.set(view);
this.sidebarSearchTerm.set('');
}
selectNote(noteId: string): void {
const note = this.vaultService.getNoteById(noteId);
if (!note) {
return;
}
this.vaultService.ensureFolderOpen(note.originalPath);
this.selectedNoteId.set(note.id);
if (!this.isDesktopView() && this.activeView() === 'search') {
this.isSidebarOpen.set(false);
}
}
updateSearchTerm(term: string, focusSearch = false): void {
this.sidebarSearchTerm.set(term ?? '');
if (focusSearch || (term && term.trim().length > 0)) {
this.activeView.set('search');
}
}
handleWikiLink(link: WikiLinkActivation): void {
const target = link.target?.trim();
if (!target) {
return;
}
const note = this.resolveWikiTarget(target);
if (!note) {
console.warn('[ObsiViewer] Wiki link target not found:', link);
return;
}
this.pendingWikiNavigation.set({
noteId: note.id,
heading: link.heading,
block: link.block
});
this.selectNote(note.id);
}
private generateToc(html: string): void {
const toc: TocEntry[] = [];
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
headings.forEach(heading => {
if (!heading.id || !heading.textContent) {
return;
}
toc.push({
level: parseInt(heading.tagName.substring(1), 10),
text: heading.textContent,
id: heading.id
});
});
this.tableOfContents.set(toc);
}
private scrollToHeading(id: string): void {
const contentArea = (this.elementRef.nativeElement as HTMLElement).querySelector('.note-content-area');
if (!contentArea) {
return;
}
const element = contentArea.querySelector(`#${id}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
private scrollToBlock(blockId: string | undefined): void {
if (!blockId) {
return;
}
const contentArea = (this.elementRef.nativeElement as HTMLElement).querySelector('.note-content-area');
if (!contentArea) {
return;
}
const selectors = [`[data-block-id="${blockId}"]`, `#${blockId}`];
for (const selector of selectors) {
const element = contentArea.querySelector(selector);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
break;
}
}
}
private resolveWikiTarget(rawTarget: string): Note | undefined {
const normalized = rawTarget.trim();
if (!normalized) {
return undefined;
}
const lower = normalized.toLowerCase();
const slug = this.slugifyForWiki(normalized);
const normalizedPath = this.normalizePath(normalized);
const notes = this.vaultService.allNotes();
return notes.find(note => {
const title = note.title?.trim() ?? '';
const titleLower = title.toLowerCase();
const titleSlug = this.slugifyForWiki(title);
const fileBase = note.fileName.replace(/\.md$/i, '').trim();
const fileLower = fileBase.toLowerCase();
const filePathNormalized = this.normalizePath(fileBase);
const originalPath = this.normalizePath(note.originalPath);
const aliasMatch = Array.isArray(note.frontmatter?.aliases) && (note.frontmatter.aliases as string[]).some(alias => {
const trimmed = alias.trim();
const aliasLower = trimmed.toLowerCase();
return aliasLower === lower || this.slugifyForWiki(trimmed) === slug;
});
return (
note.id === lower ||
note.id === slug ||
titleLower === lower ||
titleSlug === slug ||
fileLower === lower ||
filePathNormalized === normalizedPath ||
originalPath === normalizedPath ||
aliasMatch
);
});
}
private slugifyForWiki(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.trim()
.replace(/\s+/g, '-');
}
private normalizePath(path: string): string {
return path
.replace(/\\/g, '/')
.replace(/\.md$/i, '')
.replace(/^\/+/, '')
.toLowerCase();
}
}