import { Component, EventEmitter, HostListener, Input, Output, inject, effect, AfterViewInit, ViewChild, ElementRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { UiModeService } from '../../shared/services/ui-mode.service'; import { ResponsiveService } from '../../shared/services/responsive.service'; import { MobileNavService } from '../../shared/services/mobile-nav.service'; import { FileExplorerComponent } from '../../../components/file-explorer/file-explorer.component'; import { NoteViewerComponent } from '../../../components/tags-view/note-viewer/note-viewer.component'; import { VaultService } from '../../../services/vault.service'; import type { VaultNode, Note, TagInfo } from '../../../types'; import { AppBottomNavigationComponent } from '../../features/bottom-nav/app-bottom-navigation.component'; import { AppSidebarDrawerComponent } from '../../features/sidebar/app-sidebar-drawer.component'; import { AppTocOverlayComponent } from '../../features/note-view/app-toc-overlay.component'; import { SwipeNavDirective } from '../../shared/directives/swipe-nav.directive'; import { PaginatedNotesListComponent } from '../../features/list/paginated-notes-list.component'; import { NimbusSidebarComponent } from '../../features/sidebar/nimbus-sidebar.component'; import { QuickLinksComponent } from '../../features/quick-links/quick-links.component'; import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive'; import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playground/markdown-playground.component'; import { TestsPanelComponent } from '../../features/tests/tests-panel.component'; import { TestExcalidrawPageComponent } from '../../features/tests/test-excalidraw-page.component'; import { ParametersPage } from '../../features/parameters/parameters.page'; import { AboutPanelComponent } from '../../features/about/about-panel.component'; import { UrlStateService } from '../../services/url-state.service'; import { FilterService } from '../../services/filter.service'; import { NoteInfoModalComponent } from '../../features/note-info/note-info-modal.component'; import { NoteInfoModalService } from '../../services/note-info-modal.service'; import { InPageSearchService } from '../../shared/search/in-page-search.service'; import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search-overlay.component'; @Component({ selector: 'app-shell-nimbus-layout', standalone: true, imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, PaginatedNotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent, TestsPanelComponent, TestExcalidrawPageComponent, ParametersPage, AboutPanelComponent, NoteInfoModalComponent, InPageSearchOverlayComponent], template: `
{{ f === 'quick' ? 'Quick Links' : (f === 'folders' ? 'Folders' : (f === 'tags' ? 'Tags' : (f === 'trash' ? 'Trash' : (f === 'help' ? 'Help' : (f === 'about' ? 'About' : (f === 'tests' ? 'Tests' : (f === 'playground' ? 'Playground' : ''))))))) }}
La corbeille est vide
Empty
@if (mobileNav.activeTab() === 'list') {
} @if (mobileNav.activeTab() === 'page') { @if (activeView === 'parameters') {
} @else if (activeView === 'markdown-playground') {
} @else if (activeView === 'tests-panel') {
} @else if (activeView === 'tests-excalidraw') {
} @else {
@if (selectedNote) { } @else {
📄

Aucune page sélectionnée pour le moment.

}
} }
` }) export class AppShellNimbusLayoutComponent implements AfterViewInit { ui = inject(UiModeService); vault = inject(VaultService); responsive = inject(ResponsiveService); mobileNav = inject(MobileNavService); urlState = inject(UrlStateService); filters = inject(FilterService); noteInfo = inject(NoteInfoModalService); inPageSearch = inject(InPageSearchService); noteFullScreen = false; showAboutPanel = false; @Input() vaultName = ''; @Input() effectiveFileTree: VaultNode[] = []; @Input() selectedNoteId: string | null = ''; @Input() selectedNote: Note | undefined; @Input() renderedNoteContent = ''; @Input() tableOfContents: Array<{ level: number; text: string; id: string }> = []; @Input() isSidebarOpen = true; @Input() isOutlineOpen = false; @Input() leftSidebarWidth = 288; @Input() rightSidebarWidth = 288; @Input() searchTerm = ''; @Input() centerPanelWidth = 384; @Input() tags: TagInfo[] = []; @Input() activeView: string = 'files'; @ViewChild('pageRoot', { static: false }) pageRoot?: ElementRef; @Output() noteSelected = new EventEmitter(); @Output() tagClicked = new EventEmitter(); @Output() wikiLinkActivated = new EventEmitter(); @Output() toggleSidebarRequest = new EventEmitter(); @Output() toggleOutlineRequest = new EventEmitter(); @Output() leftResizeStart = new EventEmitter(); @Output() rightResizeStart = new EventEmitter(); @Output() centerResizeStart = new EventEmitter(); @Output() navigateHeading = new EventEmitter(); @Output() searchTermChange = new EventEmitter(); @Output() searchOptionsChange = new EventEmitter(); @Output() noteCreated = new EventEmitter(); @Output() noteCreatedAndSelected = new EventEmitter<{ id: string; filePath: string }>(); @Output() markdownPlaygroundSelected = new EventEmitter(); @Output() parametersOpened = new EventEmitter(); @Output() helpPageRequested = new EventEmitter(); @Output() testsPanelRequested = new EventEmitter(); @Output() testsExcalidrawRequested = new EventEmitter(); folderFilter: string | null = null; listQuery: string = ''; hoveredFlyout: 'quick' | 'folders' | 'tags' | 'trash' | 'help' | 'about' | 'tests' | 'playground' | null = null; private flyoutCloseTimer: any = null; tagFilter: string | null = null; quickLinkFilter: 'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null = null; private suppressNextNoteSelection = false; // --- URL State <-> Layout sync --- private mapUrlQuickToInternal(q: string | null): AppShellNimbusLayoutComponent['quickLinkFilter'] { switch ((q || '').toLowerCase()) { case 'favoris': case 'favorites': return 'favoris'; case 'publié': case 'publie': case 'publish': return 'publish'; case 'brouillons': case 'drafts': case 'draft': return 'draft'; case 'modèles': case 'modeles': case 'templates': case 'template': return 'template'; case 'tâches': case 'taches': case 'tasks': case 'task': return 'task'; case 'privé': case 'prive': case 'private': return 'private'; case 'archive': return 'archive'; default: return null; } } private mapInternalQuickToUrl(id: AppShellNimbusLayoutComponent['quickLinkFilter']): string | null { switch (id) { case 'favoris': return 'Favoris'; case 'publish': return 'Publié'; case 'draft': return 'Brouillons'; case 'template': return 'Modèles'; case 'task': return 'Tâches'; case 'private': return 'Privé'; case 'archive': return 'Archive'; default: return null; } } // React to URL state changes and align layout _urlEffect = effect(() => { const note = this.urlState.currentNote(); const tag = this.urlState.activeTag(); const folder = this.urlState.activeFolder(); const quick = this.urlState.activeQuickLink(); const search = this.urlState.activeSearch(); console.log('🎨 Layout _urlEffect:', {note, tag, folder, quick, search}); // Apply search query if (search !== null && this.listQuery !== (search || '')) { this.listQuery = search || ''; } // If a note is specified, select it and focus page, but DO NOT early-return: // we still want to apply list filters (tag/folder/quick) from the URL so the list matches. const hasNote = !!note; if (hasNote) { if (this.selectedNoteId !== note!.id) { this.noteSelected.emit(note!.id); } // Ensure page view visible on small screens if (!this.responsive.isDesktop()) { this.mobileNav.setActiveTab('page'); } // Exit fullscreen if needed if (this.noteFullScreen) { this.noteFullScreen = false; document.body.classList.remove('note-fullscreen-active'); } } // Otherwise, synchronize filters from URL if (tag !== null) { const norm = (tag || '').replace(/^#/, '').trim().toLowerCase(); if (this.tagFilter !== norm) { this.tagFilter = norm || null; this.folderFilter = null; this.quickLinkFilter = null; if (!hasNote) this.autoSelectFirstNote(); if (!this.responsive.isDesktop()) this.mobileNav.setActiveTab('list'); } // Auto-open tags flyout when tag filter is active if (this.hoveredFlyout !== 'tags') { console.log('🎨 Layout - opening tags flyout for tag filter'); this.openFlyout('tags'); } } else if (folder !== null) { if (this.folderFilter !== (folder || null)) { this.folderFilter = folder || null; this.tagFilter = null; this.quickLinkFilter = null; if (!hasNote) this.autoSelectFirstNote(); if (!this.responsive.isDesktop()) this.mobileNav.setActiveTab('list'); } // Auto-open folders flyout when folder filter is active if (this.hoveredFlyout !== 'folders') { console.log('🎨 Layout - opening folders flyout for folder filter'); this.openFlyout('folders'); } } else if (quick !== null) { const internal = this.mapUrlQuickToInternal(quick); if (this.quickLinkFilter !== internal) { this.quickLinkFilter = internal; this.folderFilter = null; this.tagFilter = null; if (internal === 'favoris') { this.suppressNextNoteSelection = true; } if (!hasNote && !this.suppressNextNoteSelection) { this.autoSelectFirstNote(); } if (!this.responsive.isDesktop()) { this.mobileNav.setActiveTab('list'); } } else if (!hasNote && !this.suppressNextNoteSelection) { this.autoSelectFirstNote(); } // Auto-open quick flyout when quick filter is active if (this.hoveredFlyout !== 'quick') { console.log('🎨 Layout - opening quick flyout for quick filter'); this.openFlyout('quick'); } this.suppressNextNoteSelection = false; } else { // No filters -> show all if (this.folderFilter || this.tagFilter || this.quickLinkFilter) { this.folderFilter = null; this.tagFilter = null; this.quickLinkFilter = null; if (!hasNote) this.autoSelectFirstNote(); } this.suppressNextNoteSelection = false; // Close any open flyout when no filters if (this.hoveredFlyout) { console.log('🎨 Layout - closing flyout (no active filters)'); this.scheduleCloseFlyout(0); } } console.log('🎨 Layout filters after:', { tagFilter: this.tagFilter, folderFilter: this.folderFilter, quickLinkFilter: this.quickLinkFilter, hoveredFlyout: this.hoveredFlyout }); }); // Auto-select first note when filters change private autoSelectFirstNote() { const filteredNotes = this.getFilteredNotes(); if (filteredNotes.length > 0 && filteredNotes[0].id !== this.selectedNoteId) { this.noteSelected.emit(filteredNotes[0].id); } } private getFilteredNotes(): Note[] { const q = (this.listQuery || '').toLowerCase().trim(); const folder = (this.folderFilter || '').toLowerCase().replace(/^\/+|\/+$/g, ''); const tag = (this.tagFilter || '').toLowerCase(); const quickLink = this.quickLinkFilter; let list = this.vault.allNotes(); // Exclude trash notes by default unless specifically viewing trash if (folder !== '.trash') { list = list.filter(n => { const filePath = (n.filePath || n.originalPath || '').toLowerCase().replace(/\\/g, '/'); return !filePath.startsWith('.trash/') && !filePath.includes('/.trash/'); }); } if (folder) { if (folder === '.trash') { // All files anywhere under .trash (including subfolders) list = list.filter(n => { const filePath = (n.filePath || n.originalPath || '').toLowerCase().replace(/\\/g, '/'); return filePath.startsWith('.trash/') || filePath.includes('/.trash/'); }); } else { list = list.filter(n => { const originalPath = (n.originalPath || '').toLowerCase().replace(/^\/+|\/+$/g, ''); return originalPath === folder || originalPath.startsWith(folder + '/'); }); } } if (tag) { list = list.filter(n => Array.isArray(n.tags) && n.tags.some(t => (t || '').toLowerCase() === tag)); } // Apply Quick Link filter if (quickLink) { list = list.filter(n => { const frontmatter = n.frontmatter || {}; return frontmatter[quickLink] === true; }); } // Apply query if present if (q) { list = list.filter(n => { const title = (n.title || '').toLowerCase(); const filePath = (n.filePath || '').toLowerCase(); return title.includes(q) || filePath.includes(q); }); } // Sort by most recent first (same as notes-list component) const parseDate = (s?: string) => (s ? Date.parse(s) : 0) || 0; const score = (n: Note) => n.mtime || parseDate(n.updatedAt) || parseDate(n.createdAt) || 0; return [...list].sort((a, b) => (score(b) - score(a))); } ngAfterViewInit(): void { queueMicrotask(() => this.inPageSearch.setRoot(this.pageRoot?.nativeElement || null)); } @HostListener('document:keydown', ['$event']) onKeydown(e: KeyboardEvent) { const isFind = (e.ctrlKey || e.metaKey) && (e.key === 'f' || e.key === 'F'); if (isFind) { e.preventDefault(); this.openInPageSearch(); } else if (e.key === 'Escape' && this.inPageSearch.openState()) { this.inPageSearch.close(); } else if (e.key === 'Enter' && this.inPageSearch.openState()) { if (e.shiftKey) this.inPageSearch.prev(); else this.inPageSearch.next(); e.preventDefault(); } } openInPageSearch(): void { this.inPageSearch.open(); document.dispatchEvent(new CustomEvent('ov-search-focus')); this.inPageSearch.setRoot(this.pageRoot?.nativeElement || null); } onQueryChange(query: string) { this.listQuery = query; // Only auto-select when query is cleared; while typing keep focus in search (handled by notes-list) if (!query) { this.autoSelectFirstNote(); } // Sync URL search term this.urlState.updateSearch(query); } toggleNoteFullScreen(): void { this.noteFullScreen = !this.noteFullScreen; document.body.classList.toggle('note-fullscreen-active', this.noteFullScreen); } /** Reuse the same behavior when a global fullscreen request event is dispatched */ @HostListener('window:noteFullScreenRequested', ['$event']) onNoteFullScreenRequested(_evt: CustomEvent) { this.toggleNoteFullScreen(); } nextTab() { const order: Array<'sidebar' | 'list' | 'page' | 'toc'> = ['sidebar', 'list', 'page', 'toc']; const idx = order.indexOf(this.mobileNav.activeTab()); const next = order[Math.min(order.length - 1, idx + 1)] as any; this.mobileNav.setActiveTab(next); } prevTab() { const order: Array<'sidebar' | 'list' | 'page' | 'toc'> = ['sidebar', 'list', 'page', 'toc']; const idx = order.indexOf(this.mobileNav.activeTab()); const prev = order[Math.max(0, idx - 1)] as any; this.mobileNav.setActiveTab(prev); } async onOpenNote(target: string) { try { console.debug('[Nimbus] onOpenNote', target); } catch {} let filePath: string | null = null; let noteId: string = target; try { const looksLikePath = /[/\\]/.test(target) || /\.[a-z0-9]+$/i.test(target); if (looksLikePath) { filePath = target.replace(/\\/g, '/'); try { await this.vault.ensureNoteLoadedByPath(filePath); } catch {} try { noteId = this.vault.buildSlugIdFromPath(filePath); } catch {} } else { await this.vault.ensureNoteLoadedById(target); const n = (this.vault.allNotes() || []).find(x => x.id === target); filePath = n?.filePath || this.vault.getFastMetaById(target)?.path || null; } if (filePath) { try { console.debug('[Nimbus] opening note path', filePath); } catch {} this.urlState.openNote(filePath); } else { try { console.warn('[Nimbus] onOpenNote: no filePath resolved for', target); } catch {} } } finally { try { console.debug('[Nimbus] emitting noteSelected', noteId); } catch {} this.noteSelected.emit(noteId); } } onClearFolderFromList() { this.folderFilter = null; this.urlState.clearFolderFilter(); } onAboutSelected(): void { this.showAboutPanel = true; } onNoteCreated(noteId: string) { this.noteCreated.emit(noteId); } onNoteCreatedAndSelected(event: { id: string; filePath: string }) { this.noteCreatedAndSelected.emit(event); } onNoteSelectedMobile(noteId: string) { if (!noteId) return; this.noteSelected.emit(noteId); this.mobileNav.setActiveTab('page'); } onFolderSelected(path: string) { this.folderFilter = path || null; // Reset other filters and search when focusing a folder to prevent residual constraints this.tagFilter = null; this.quickLinkFilter = null; this.listQuery = ''; try { this.filters.clearKinds(); } catch {} try { this.filters.clearTags(); } catch {} this.autoSelectFirstNote(); if (this.responsive.isMobile() || this.responsive.isTablet()) { this.mobileNav.setActiveTab('list'); } // Reflect folder in URL if (path) { // Clear search in URL to avoid residual query re-applying via URL effect try { this.urlState.updateSearch(''); } catch {} this.urlState.filterByFolder(path); } else { this.urlState.resetState(); } } onFolderSelectedFromDrawer(path: string) { this.folderFilter = path || null; // Reset other filters and search when focusing a folder to prevent residual constraints this.tagFilter = null; this.quickLinkFilter = null; this.listQuery = ''; try { this.filters.clearKinds(); } catch {} try { this.filters.clearTags(); } catch {} this.autoSelectFirstNote(); this.mobileNav.setActiveTab('list'); this.mobileNav.sidebarOpen.set(false); if (path) { // Clear search in URL to avoid residual query re-applying via URL effect try { this.urlState.updateSearch(''); } catch {} this.urlState.filterByFolder(path); } else { this.urlState.resetState(); } } onQuickLink(_id: string) { const suppressAutoSelect = _id === 'all' || _id === 'favorites'; this.suppressNextNoteSelection = suppressAutoSelect; if (_id === 'all') { // Show all pages: clear filters and focus list this.folderFilter = null; this.tagFilter = null; this.quickLinkFilter = null; this.listQuery = ''; // Clear local filters (kinds, cumulative tags) to avoid conflicting filters try { this.filters.clearKinds(); } catch {} try { this.filters.clearTags(); } catch {} if (!this.responsive.isDesktop()) { this.mobileNav.setActiveTab('list'); } this.scheduleCloseFlyout(150); this.urlState.setQuickWithMarkdown('all'); } else if (_id === 'publish') { // Filter by publish: true this.folderFilter = null; this.tagFilter = null; this.quickLinkFilter = 'publish'; this.listQuery = ''; try { this.filters.clearKinds(); } catch {} try { this.filters.clearTags(); } catch {} if (!this.responsive.isDesktop()) { this.mobileNav.setActiveTab('list'); } this.scheduleCloseFlyout(150); const label = this.mapInternalQuickToUrl('publish'); if (label) { this.urlState.setQuickWithMarkdown(label); } } else if (_id === 'favorites') { // Filter by favoris: true this.folderFilter = null; this.tagFilter = null; this.quickLinkFilter = 'favoris'; this.listQuery = ''; try { this.filters.clearKinds(); } catch {} try { this.filters.clearTags(); } catch {} if (!this.responsive.isDesktop()) { this.mobileNav.setActiveTab('list'); } this.scheduleCloseFlyout(150); const label = this.mapInternalQuickToUrl('favoris'); if (label) { this.urlState.setQuickWithMarkdown(label); } } else if (_id === 'templates') { // Filter by template: true this.folderFilter = null; this.tagFilter = null; this.quickLinkFilter = 'template'; this.listQuery = ''; try { this.filters.clearKinds(); } catch {} try { this.filters.clearTags(); } catch {} if (!this.responsive.isDesktop()) { this.mobileNav.setActiveTab('list'); } this.scheduleCloseFlyout(150); const label = this.mapInternalQuickToUrl('template'); if (label) { this.urlState.setQuickWithMarkdown(label); } } else if (_id === 'tasks') { // Filter by task: true this.folderFilter = null; this.tagFilter = null; this.quickLinkFilter = 'task'; this.listQuery = ''; try { this.filters.clearKinds(); } catch {} try { this.filters.clearTags(); } catch {} if (!this.responsive.isDesktop()) { this.mobileNav.setActiveTab('list'); } this.scheduleCloseFlyout(150); const label = this.mapInternalQuickToUrl('task'); if (label) { this.urlState.setQuickWithMarkdown(label); } } else if (_id === 'drafts') { // Filter by draft: true this.folderFilter = null; this.tagFilter = null; this.quickLinkFilter = 'draft'; this.listQuery = ''; try { this.filters.clearKinds(); } catch {} try { this.filters.clearTags(); } catch {} if (!this.responsive.isDesktop()) { this.mobileNav.setActiveTab('list'); } this.scheduleCloseFlyout(150); const label = this.mapInternalQuickToUrl('draft'); if (label) { this.urlState.setQuickWithMarkdown(label); } } else if (_id === 'private') { // Filter by private: true this.folderFilter = null; this.tagFilter = null; this.quickLinkFilter = 'private'; this.listQuery = ''; try { this.filters.clearKinds(); } catch {} try { this.filters.clearTags(); } catch {} if (!this.responsive.isDesktop()) { this.mobileNav.setActiveTab('list'); } this.scheduleCloseFlyout(150); const label = this.mapInternalQuickToUrl('private'); if (label) { this.urlState.setQuickWithMarkdown(label); } } else if (_id === 'archive') { // Filter by archive: true this.folderFilter = null; this.tagFilter = null; this.quickLinkFilter = 'archive'; this.listQuery = ''; try { this.filters.clearKinds(); } catch {} try { this.filters.clearTags(); } catch {} if (!this.responsive.isDesktop()) { this.mobileNav.setActiveTab('list'); } this.scheduleCloseFlyout(150); const label = this.mapInternalQuickToUrl('archive'); if (label) { this.urlState.setQuickWithMarkdown(label); } } // Auto-select first note after filter changes if (!this.suppressNextNoteSelection) { this.autoSelectFirstNote(); } this.suppressNextNoteSelection = false; } onTagSelected(tagName: string) { const norm = (tagName || '').replace(/^#/, '').trim().toLowerCase(); if (!norm) return; this.tagFilter = norm; this.folderFilter = null; // clear folder when focusing tag // Clear other filters and search to focus on tag results this.quickLinkFilter = null; this.listQuery = ''; // Auto-select first note after filter changes this.autoSelectFirstNote(); // Ensure the list is visible: exit fullscreen if active if (this.noteFullScreen) { this.noteFullScreen = false; document.body.classList.remove('note-fullscreen-active'); } // Bubble up for global handlers (keeps parity with right sidebar tags) this.tagClicked.emit(norm); if (!this.responsive.isDesktop()) { this.mobileNav.setActiveTab('list'); } // If from flyout, do not close immediately; small delay allows click feedback this.scheduleCloseFlyout(200); // Reflect in URL using original (non-normalized) tag label if (tagName) { this.urlState.filterByTag(tagName.replace(/^#/, '').trim()); } } openFlyout(which: 'quick' | 'folders' | 'tags' | 'trash') { this.cancelCloseFlyout(); this.hoveredFlyout = which; } scheduleCloseFlyout(delay = 200) { this.cancelCloseFlyout(); this.flyoutCloseTimer = setTimeout(() => { this.hoveredFlyout = null; this.flyoutCloseTimer = null; }, delay); } cancelCloseFlyout() { if (this.flyoutCloseTimer) { clearTimeout(this.flyoutCloseTimer); this.flyoutCloseTimer = null; } } onMarkdownPlaygroundSelected(): void { if (this.responsive.isMobile()) { this.mobileNav.setActiveTab('page'); } this.markdownPlaygroundSelected.emit(); } onTestsPanelSelected(): void { if (this.responsive.isMobile()) { this.mobileNav.setActiveTab('page'); } this.testsPanelRequested.emit(); } onTestsExcalidrawSelected(): void { if (this.responsive.isMobile()) { this.mobileNav.setActiveTab('page'); } this.testsExcalidrawRequested.emit(); } onParametersOpen(): void { this.parametersOpened.emit(); } onTocNavigate(headingId: string): void { // Ensure the page view is visible so the scroll container exists this.mobileNav.setActiveTab('page'); // Close the TOC overlay immediately if (this.mobileNav.tocOpen()) { this.mobileNav.toggleToc(); } // Wait for DOM to update before scrolling setTimeout(() => { this.navigateHeading.emit(headingId); }, 100); } onHelpPageSelected(): void { this.helpPageRequested.emit(); } onClearQuickLinkFilter(): void { this.folderFilter = null; this.tagFilter = null; this.quickLinkFilter = null; this.listQuery = ''; this.autoSelectFirstNote(); } }