import { Component, ChangeDetectionStrategy, HostListener, inject, signal, computed, effect, ElementRef, OnDestroy, OnInit } 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'; import { MarkdownViewerService } from './services/markdown-viewer.service'; import { DownloadService } from './core/services/download.service'; import { ThemeService } from './app/core/services/theme.service'; import { LogService } from './core/logging/log.service'; // Components import { FileExplorerComponent } from './components/file-explorer/file-explorer.component'; import { NoteViewerComponent, WikiLinkActivation } from './components/tags-view/note-viewer/note-viewer.component'; import { GraphViewContainerV2Component } from './components/graph-view-container-v2/graph-view-container-v2.component'; import { TagsViewComponent } from './components/tags-view/tags-view.component'; import { MarkdownCalendarComponent } from './components/markdown-calendar/markdown-calendar.component'; import { GraphInlineSettingsComponent } from './app/graph/ui/inline-settings-panel.component'; import { RawViewOverlayComponent } from './shared/overlays/raw-view-overlay.component'; import { BookmarksPanelComponent } from './components/bookmarks-panel/bookmarks-panel.component'; import { AddBookmarkModalComponent, type BookmarkFormData, type BookmarkDeleteEvent } from './components/add-bookmark-modal/add-bookmark-modal.component'; import { BookmarksService } from './core/bookmarks/bookmarks.service'; import { SearchInputWithAssistantComponent } from './components/search-input-with-assistant/search-input-with-assistant.component'; import { SearchHistoryService } from './core/search/search-history.service'; import { GraphIndexService } from './core/graph/graph-index.service'; // 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, GraphViewContainerV2Component, TagsViewComponent, MarkdownCalendarComponent, RawViewOverlayComponent, BookmarksPanelComponent, AddBookmarkModalComponent, GraphInlineSettingsComponent, SearchInputWithAssistantComponent, ], templateUrl: './app.component.simple.html', styleUrls: ['./app.component.css'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent implements OnInit, OnDestroy { readonly vaultService = inject(VaultService); private markdownService = inject(MarkdownService); private markdownViewerService = inject(MarkdownViewerService); private downloadService = inject(DownloadService); private readonly themeService = inject(ThemeService); private readonly bookmarksService = inject(BookmarksService); private readonly searchHistoryService = inject(SearchHistoryService); private readonly graphIndexService = inject(GraphIndexService); private readonly logService = inject(LogService); private elementRef = inject(ElementRef); // --- State Signals --- isSidebarOpen = signal(true); isOutlineOpen = signal(true); outlineTab = signal<'outline' | 'settings'>('outline'); activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks'>('files'); selectedNoteId = signal(''); sidebarSearchTerm = signal(''); tableOfContents = signal([]); leftSidebarWidth = signal(288); rightSidebarWidth = signal(288); isRawViewOpen = signal(false); isRawViewWrapped = signal(true); showAddBookmarkModal = signal(false); readonly LEFT_MIN_WIDTH = 220; readonly LEFT_MAX_WIDTH = 520; readonly RIGHT_MIN_WIDTH = 220; readonly RIGHT_MAX_WIDTH = 520; private rawViewTriggerElement: HTMLElement | null = null; 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); readonly isDarkMode = this.themeService.isDark; // Bookmark state readonly isCurrentNoteBookmarked = computed(() => { const noteId = this.selectedNoteId(); if (!noteId) return false; const note = this.selectedNote(); if (!note) return false; const doc = this.bookmarksService.doc(); const notePath = note.filePath; const findBookmark = (items: any[]): boolean => { for (const item of items) { if (item.type === 'file' && item.path === notePath) { return true; } if (item.type === 'group' && item.items) { if (findBookmark(item.items)) return true; } } return false; }; return findBookmark(doc.items); }); // --- 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); }); rawNoteContent = computed(() => { const note = this.selectedNote(); if (!note) { return ''; } return note.rawContent ?? note.content ?? ''; }); rawNoteFilename = computed(() => { const note = this.selectedNote(); if (!note) { return this.buildFallbackFilename(); } const name = note.fileName?.trim(); return name && name.length > 0 ? name : this.buildFallbackFilename(); }); 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; }); activeTagDisplay = computed(() => { const rawTerm = this.sidebarSearchTerm().trim(); if (!rawTerm.startsWith('#')) { return null; } const displayTag = rawTerm.slice(1).trim(); return displayTag || 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() { this.themeService.initFromStorage(); 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); } // Initialize outline tab from localStorage if (typeof window !== 'undefined') { const savedTab = window.localStorage.getItem('graphPaneTab'); if (savedTab === 'settings' || savedTab === 'outline') { this.outlineTab.set(savedTab as 'outline' | 'settings'); } } 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 note = this.selectedNote(); this.markdownViewerService.setCurrentNote(note ?? null); const html = this.renderedNoteContent(); if (html && note) { this.generateToc(html); } else { this.tableOfContents.set([]); } }); // Effect to rebuild graph index when notes change effect(() => { const notes = this.vaultService.allNotes(); this.graphIndexService.rebuildIndex(notes); }); // Persist outline tab effect(() => { const tab = this.outlineTab(); if (typeof window !== 'undefined') { window.localStorage.setItem('graphPaneTab', tab); } }); effect(() => { if (!this.selectedNote()) { this.isRawViewOpen.set(false); } }); // Check for reduced motion preference for testing if (typeof window !== 'undefined') { const params = new URLSearchParams(window.location.search); if (params.has('reduced-motion')) { document.body.classList.add('prefers-reduced-motion'); } } 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); } }); } ngOnInit(): void { // Log app start this.logService.log('APP_START', { viewport: { width: typeof window !== 'undefined' ? window.innerWidth : 0, height: typeof window !== 'undefined' ? window.innerHeight : 0, }, }); } ngOnDestroy(): void { // Log app stop this.logService.log('APP_STOP'); if (typeof window !== 'undefined') { window.removeEventListener('resize', this.resizeHandler); } } // --- Methods --- toggleTheme(): void { this.themeService.toggleTheme(); } 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); } setOutlineTab(tab: 'outline' | 'settings'): void { this.outlineTab.set(tab); } 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; // Log calendar search execution if (files.length > 0) { this.logService.log('CALENDAR_SEARCH_EXECUTED', { resultsCount: files.length, }); } } } 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'); } /** * Clear the current calendar search results and related UI state. * Used by the "Effacer" button in the search panel. */ clearCalendarResults(): void { this.calendarResults.set([]); this.calendarSearchState.set('idle'); this.calendarSearchError.set(null); this.calendarSelectionLabel.set(null); } 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' | 'bookmarks'): void { const previousView = this.activeView(); this.activeView.set(view); this.sidebarSearchTerm.set(''); // Log view changes if (view === 'bookmarks' && previousView !== 'bookmarks') { this.logService.log('BOOKMARKS_OPEN'); } else if (view === 'graph' && previousView !== 'graph') { this.logService.log('GRAPH_VIEW_OPEN'); } } selectNote(noteId: string): void { const note = this.vaultService.getNoteById(noteId); if (!note) { return; } this.vaultService.ensureFolderOpen(note.originalPath); this.selectedNoteId.set(note.id); this.markdownViewerService.setCurrentNote(note); if (!this.isDesktopView() && this.activeView() === 'search') { this.isSidebarOpen.set(false); } } selectNoteFromGraph(noteId: string): void { this.selectNote(noteId); this.activeView.set('files'); } handleTagClick(tagName: string): void { const normalized = tagName.replace(/^#/, '').trim(); if (!normalized) { return; } this.isSidebarOpen.set(true); this.activeView.set('search'); this.sidebarSearchTerm.set(`#${normalized}`); } updateSearchTerm(term: string, focusSearch = false): void { this.sidebarSearchTerm.set(term ?? ''); if (focusSearch || (term && term.trim().length > 0)) { this.activeView.set('search'); } } onSearchSubmit(query: string): void { if (query && query.trim()) { // History is already handled by SearchInputWithAssistant using its [context] // Update search term and switch to search view this.sidebarSearchTerm.set(query); this.activeView.set('search'); // Log search execution this.logService.log('SEARCH_EXECUTED', { query: query.trim(), queryLength: query.trim().length, }); } } toggleRawView(): void { if (!this.selectedNote()) { return; } if (this.isRawViewOpen()) { this.closeRawView(); } else { this.openRawView(); } } openRawView(): void { if (!this.selectedNote()) { return; } if (typeof document !== 'undefined') { this.rawViewTriggerElement = document.activeElement as HTMLElement | null; } this.isRawViewOpen.set(true); } closeRawView(): void { this.isRawViewOpen.set(false); const target = this.rawViewTriggerElement; this.rawViewTriggerElement = null; if (target && typeof target.focus === 'function') { queueMicrotask(() => target.focus()); } } toggleRawWrap(): void { this.isRawViewWrapped.update(value => !value); } downloadCurrentNote(): void { const note = this.selectedNote(); if (!note) { return; } const filename = this.rawNoteFilename(); const content = this.rawNoteContent(); if (!content) { console.warn('[ObsiViewer] Aucun contenu à télécharger.'); return; } this.downloadService.downloadText(content, filename); } toggleBookmarkModal(): void { this.showAddBookmarkModal.update(v => !v); } closeBookmarkModal(): void { this.showAddBookmarkModal.set(false); } onBookmarkSave(data: BookmarkFormData): void { const note = this.selectedNote(); if (!note) return; // Check if bookmark already exists const doc = this.bookmarksService.doc(); let existingCtime: number | null = null; const findExisting = (items: any[]): number | null => { for (const item of items) { if (item.type === 'file' && item.path === note.filePath) { return item.ctime; } if (item.type === 'group' && item.items) { const found = findExisting(item.items); if (found) return found; } } return null; }; existingCtime = findExisting(doc.items); if (existingCtime) { // Update existing bookmark this.bookmarksService.updateBookmark(existingCtime, { title: data.title, }); // If group changed, move it if (data.groupCtime !== null) { this.bookmarksService.moveBookmark(existingCtime, data.groupCtime, 0); } // Log bookmark modification this.logService.log('BOOKMARKS_MODIFY', { action: 'update', path: data.path, }); } else { // Create new bookmark this.bookmarksService.createFileBookmark(data.path, data.title, data.groupCtime); // Log bookmark modification this.logService.log('BOOKMARKS_MODIFY', { action: 'add', path: data.path, }); } this.closeBookmarkModal(); } onBookmarkDelete(event: BookmarkDeleteEvent): void { this.bookmarksService.removePathEverywhere(event.path); // Log bookmark deletion this.logService.log('BOOKMARKS_MODIFY', { action: 'delete', path: event.path, }); this.closeBookmarkModal(); } onBookmarkNavigate(bookmark: any): void { if (bookmark.type === 'file' && bookmark.path) { // Find note by matching filePath const note = this.vaultService.allNotes().find(n => n.filePath === bookmark.path); if (note) { this.selectNote(note.id); } } } @HostListener('window:keydown', ['$event']) handleGlobalKeydown(event: KeyboardEvent): void { if (!event.altKey || event.repeat) { return; } const key = event.key.toLowerCase(); if (key === 'r') { if (!this.selectedNote()) { return; } event.preventDefault(); this.toggleRawView(); } if (key === 'd') { if (!this.selectedNote()) { return; } event.preventDefault(); this.downloadCurrentNote(); } } private buildFallbackFilename(): string { const now = new Date(); const pad = (value: number) => value.toString().padStart(2, '0'); const year = now.getFullYear(); const month = pad(now.getMonth() + 1); const day = pad(now.getDate()); const hours = pad(now.getHours()); const minutes = pad(now.getMinutes()); return `note-${year}${month}${day}-${hours}${minutes}.md`; } 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(); } }