feat: enhance note creation and navigation UX

- Added note creation event handlers to sync selection, scrolling and focus across components
- Improved context menu styling with better opacity and z-index handling
- Updated mobile layout to hide search input on larger screens
- Fixed focus timing issues in move-note-to-folder component
- Added URL state management for newly created notes
- Removed unused archived note files
- Added proper note ID tracking and scroll-into-view behavior
- Enhanced error
This commit is contained in:
2025-10-26 11:54:47 -04:00
parent 0ae9cae1eb
commit 917af04642
40 changed files with 658 additions and 15 deletions
+68 -3
View File
@@ -1,4 +1,4 @@
import { Component, EventEmitter, Output, computed, signal, effect, inject, ChangeDetectionStrategy } from '@angular/core';
import { Component, EventEmitter, Output, computed, signal, effect, inject, ChangeDetectionStrategy, ViewChild, ElementRef } from '@angular/core';
import { input } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { Note } from '../../../types';
@@ -9,6 +9,7 @@ import { NoteCreationService } from '../../services/note-creation.service';
import { NoteContextMenuComponent } from '../../../components/note-context-menu/note-context-menu.component';
import { WarningPanelComponent } from '../../components/warning-panel/warning-panel.component';
import { NoteContextMenuService } from '../../services/note-context-menu.service';
import { UrlStateService } from '../../services/url-state.service';
@Component({
selector: 'app-notes-list',
@@ -117,7 +118,7 @@ import { NoteContextMenuService } from '../../services/note-context-menu.service
</div>
<!-- List Container -->
<div class="flex-1 min-h-0 overflow-y-auto list-scroll" appScrollableOverlay>
<div class="flex-1 min-h-0 overflow-y-auto list-scroll" appScrollableOverlay #listContainer>
<!-- Empty state when a folder is selected but no notes match -->
<div *ngIf="(folderFilter() && filtered().length === 0)" class="h-full min-h-[240px] flex items-center justify-center text-center p-6">
<div>
@@ -133,10 +134,12 @@ import { NoteContextMenuService } from '../../services/note-context-menu.service
<ul class="notes-list">
<li *ngFor="let n of filtered()"
class="note-row cursor-pointer"
[class.active]="selectedId() === n.id"
[class.active]="(selectedId() === n.id) || (pendingSelectId() === n.id)"
[ngClass]="getListItemClasses()"
[ngStyle]="getNoteGradientStyle(n)"
[attr.data-note-path]="n.filePath"
[attr.data-note-id]="n.id"
tabindex="-1"
(click)="openNote.emit(n.id)"
(contextmenu)="openContextMenu($event, n)">
<!-- Compact View -->
@@ -373,11 +376,15 @@ export class NotesListComponent {
@Output() queryChange = new EventEmitter<string>();
@Output() clearQuickLinkFilter = new EventEmitter<void>();
@Output() noteCreated = new EventEmitter<string>();
@Output() noteCreatedAndSelected = new EventEmitter<{ id: string; filePath: string }>();
private store = inject(TagFilterStore);
readonly state = inject(NotesListStateService);
private noteCreationService = inject(NoteCreationService);
readonly contextMenuService = inject(NoteContextMenuService);
@ViewChild('listContainer') listContainer?: ElementRef<HTMLElement>;
private urlState = inject(UrlStateService);
private pendingSelectId = signal<string | null>(null);
// Delete warning modal state
deleteWarningOpen = signal<boolean>(false);
@@ -435,6 +442,45 @@ export class NotesListComponent {
}, 10);
});
private scrollToSelectedEffect = effect(() => {
const id = this.selectedId() || this.pendingSelectId();
if (!id) return;
const host = this.listContainer?.nativeElement;
if (!host) return;
const tryFocus = () => {
const el = host.querySelector(`li[data-note-id="${id}"]`) as HTMLElement | null;
if (el) {
try { el.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } catch {}
try { el.focus({ preventScroll: true } as any); } catch {}
return true;
}
return false;
};
// Attempt after microtask, then a few RAF retries to wait for DOM
queueMicrotask(() => {
if (tryFocus()) {
// If parent hasn't yet reflected selection, keep local pending highlight
return;
}
let attempts = 4;
const raf = () => {
if (tryFocus()) return;
if (--attempts > 0) requestAnimationFrame(raf);
};
requestAnimationFrame(raf);
});
});
private clearPendingWhenSynced = effect(() => {
const sel = this.selectedId();
const pending = this.pendingSelectId();
if (sel && pending && sel === pending) {
this.pendingSelectId.set(null);
}
});
private syncTagFromStore = effect(() => {
const inputTag = this.tagFilter();
if (inputTag !== null && inputTag !== undefined) {
@@ -603,8 +649,27 @@ export class NotesListComponent {
this.noteCreationService.createNote(fileName, folderPath)
.then(response => {
if (!response?.filePath || !response?.id) {
console.error('Invalid response from note creation:', response);
this.state.setRequestStats(false, 0);
return;
}
// 1) Set URL immediately with note + folder
this.urlState.setNote(response.filePath, folderPath).catch(err => {
console.error('Failed to update URL:', err);
});
// 2) Local optimistic selection
this.pendingSelectId.set(response.id);
// 3) Emit events for parent to handle scroll + focus
this.noteCreated.emit(response.id);
this.openNote.emit(response.id);
this.noteCreatedAndSelected.emit({
id: response.id,
filePath: response.filePath
});
})
.catch(error => {
console.error('Failed to create note:', error);
@@ -132,7 +132,7 @@ export class MoveNoteToFolderComponent {
queueMicrotask(() => {
this.computeMenuPosition();
this.focusSearchField();
setTimeout(() => this.focusSearchField(), 0);
});
}
@@ -308,6 +308,9 @@ export class MoveNoteToFolderComponent {
}
private focusSearchField(): void {
if (!this.showMenu()) {
return;
}
const el = this.searchField?.nativeElement;
if (el) {
el.focus();
@@ -70,6 +70,7 @@ import { UrlStateService } from '../../services/url-state.service';
(testsPanelSelected)="onTestsPanelSelected()"
(helpPageSelected)="onHelpPageSelected()"
(aboutSelected)="onAboutSelected()"
(noteCreated)="onNoteCreated($event)"
/>
</aside>
</ng-container>
@@ -185,6 +186,8 @@ import { UrlStateService } from '../../services/url-state.service';
(openNote)="onOpenNote($event)"
(queryChange)="onQueryChange($event)"
(clearQuickLinkFilter)="onClearQuickLinkFilter()"
(noteCreated)="onNoteCreated($event)"
(noteCreatedAndSelected)="onNoteCreatedAndSelected($event)"
/>
</div>
</section>
@@ -260,7 +263,7 @@ import { UrlStateService } from '../../services/url-state.service';
<app-file-explorer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" [foldersOnly]="true" [quickLinkFilter]="quickLinkFilter" (folderSelected)="onFolderSelected($event)" (fileSelected)="onOpenNote($event)"></app-file-explorer>
</div>
<div [hidden]="mobileNav.activeTab() !== 'list'" class="h-full overflow-y-auto" appScrollableOverlay>
<app-notes-list [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [query]="listQuery" [selectedId]="selectedNoteId" (queryChange)="onQueryChange($event)" (openNote)="onOpenNote($event)" (clearQuickLinkFilter)="onClearQuickLinkFilter()"></app-notes-list>
<app-notes-list [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [query]="listQuery" [selectedId]="selectedNoteId" (queryChange)="onQueryChange($event)" (openNote)="onOpenNote($event)" (clearQuickLinkFilter)="onClearQuickLinkFilter()" (noteCreated)="onNoteCreated($event)" (noteCreatedAndSelected)="onNoteCreatedAndSelected($event)"></app-notes-list>
</div>
<div [hidden]="mobileNav.activeTab() !== 'page'" class="note-content-area h-full overflow-y-auto px-3 py-4" appScrollableOverlay>
<app-markdown-playground *ngIf="activeView === 'markdown-playground'"></app-markdown-playground>
@@ -368,6 +371,8 @@ export class AppShellNimbusLayoutComponent {
@Output() navigateHeading = new EventEmitter<string>();
@Output() searchTermChange = new EventEmitter<string>();
@Output() searchOptionsChange = new EventEmitter<any>();
@Output() noteCreated = new EventEmitter<string>();
@Output() noteCreatedAndSelected = new EventEmitter<{ id: string; filePath: string }>();
@Output() markdownPlaygroundSelected = new EventEmitter<void>();
@Output() parametersOpened = new EventEmitter<void>();
@Output() helpPageRequested = new EventEmitter<void>();
@@ -632,6 +637,14 @@ export class AppShellNimbusLayoutComponent {
}
}
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);
+26
View File
@@ -348,6 +348,32 @@ export class UrlStateService implements OnDestroy {
await this.updateUrl({ search: searchTerm || undefined });
}
/**
* Définir la note et optionnellement le dossier (pour création de note)
* Utilise merge pour conserver les autres paramètres (search, etc.)
*/
async setNote(notePath: string, folderPath?: string): Promise<void> {
const normalized = (notePath ?? '').trim()
.replace(/\\/g, '/')
.replace(/^\/+/, '');
if (!normalized) {
console.warn('setNote() called with empty path');
return;
}
const queryParams: any = { note: normalized };
if (folderPath) {
queryParams.folder = folderPath.replace(/\\/g, '/').replace(/^\/+/, '');
}
await this.router.navigate([], {
queryParams,
queryParamsHandling: 'merge',
preserveFragment: true
});
}
/**
* Mettre à jour l'URL avec le nouvel état
*/