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 } 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(true); isSidebarOpen = signal(true); isOutlineOpen = signal(true); activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar'>('files'); selectedNoteId = signal(''); sidebarSearchTerm = signal(''); tableOfContents = signal([]); leftSidebarWidth = signal(288); rightSidebarWidth = signal(288); readonly LEFT_MIN_WIDTH = 220; readonly LEFT_MAX_WIDTH = 520; readonly RIGHT_MIN_WIDTH = 220; readonly RIGHT_MAX_WIDTH = 520; private viewportWidth = signal(typeof window !== 'undefined' ? window.innerWidth : 0); private resizeHandler = () => { if (typeof window === 'undefined') { return; } this.viewportWidth.set(window.innerWidth); }; isDesktopView = computed(() => this.viewportWidth() >= 1024); private wasDesktop = false; calendarResults = signal([]); calendarSearchState = signal<'idle' | 'loading' | 'error'>('idle'); calendarSearchError = signal(null); calendarSelectionLabel = signal(null); calendarOverlayVisible = computed(() => this.calendarSearchState() === 'loading' || !!this.calendarSearchError() || this.calendarResults().length > 0 ); private calendarSearchTriggered = false; // --- Data Signals --- fileTree = this.vaultService.fileTree; graphData = this.vaultService.graphData; allTags = this.vaultService.tags; vaultName = this.vaultService.vaultName; // --- Computed Signals --- selectedNote = computed(() => { const id = this.selectedNoteId(); return id ? this.vaultService.getNoteById(id) : undefined; }); renderedNoteContent = computed(() => { const note = this.selectedNote(); if (!note) return ''; const allNotes = this.vaultService.allNotes(); return this.markdownService.render(note.content, allNotes, note); }); selectedNoteBreadcrumb = computed(() => { 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(() => { 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(() => { 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(() => { const rawTerm = this.sidebarSearchTerm().trim(); if (!rawTerm.startsWith('#')) { return null; } const tag = rawTerm.slice(1).trim(); return tag ? tag.toLowerCase() : null; }); searchResults = computed(() => { 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(() => { 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); } } handleTagClick(tagName: string): void { const normalized = tagName.replace(/^#/, '').trim(); if (!normalized) { return; } this.setView('search'); this.sidebarSearchTerm.set(`#${normalized}`); } clearTagFilter(): void { this.sidebarSearchTerm.set(''); } clearCalendarResults(): void { this.calendarResults.set([]); this.calendarSearchState.set('idle'); this.calendarSearchError.set(null); this.calendarSelectionLabel.set(null); this.calendarSearchTriggered = false; } updateSearchTerm(term: string, focusSearch = false): void { this.sidebarSearchTerm.set(term ?? ''); if (focusSearch || (term && term.trim().length > 0)) { this.activeView.set('search'); } } 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) { toc.push({ level: parseInt(heading.tagName.substring(1), 10), text: heading.textContent, id: heading.id }); } }); this.tableOfContents.set(toc); } scrollToHeading(id: string): void { // The note viewer component's content area is what scrolls const contentArea = (this.elementRef.nativeElement as HTMLElement).querySelector('.note-content-area'); if(contentArea) { const element = contentArea.querySelector(`#${id}`); if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } } }