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(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; 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(() => { 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(() => { 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(); } }