feat: add URL state synchronization for navigation
- Added UrlStateService to sync app state with URL parameters for note selection, tags, folders, and search - Implemented URL state effects in AppComponent to handle navigation from URL parameters - Updated sidebar and layout components to reflect URL state changes in UI - Added URL state updates when navigating via note selection, tag clicks, and search - Modified note sharing to use URL parameters instead of route paths - Added auto-opening of relevant
This commit is contained in:
@@ -10,6 +10,7 @@ import { DownloadService } from './core/services/download.service';
|
||||
import { ThemeService } from './app/core/services/theme.service';
|
||||
import { LogService } from './core/logging/log.service';
|
||||
import { UiModeService } from './app/shared/services/ui-mode.service';
|
||||
import { UrlStateService } from './app/services/url-state.service';
|
||||
|
||||
// Components
|
||||
import { FileExplorerComponent } from './components/file-explorer/file-explorer.component';
|
||||
@@ -89,6 +90,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
private readonly logService = inject(LogService);
|
||||
private elementRef = inject(ElementRef);
|
||||
private readonly drawingsFiles = inject(DrawingsFileService);
|
||||
private readonly urlState = inject(UrlStateService);
|
||||
|
||||
// --- State Signals ---
|
||||
isSidebarOpen = signal<boolean>(true);
|
||||
@@ -655,6 +657,67 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
this.selectedNoteId.set(firstNote.id);
|
||||
}
|
||||
});
|
||||
|
||||
// === URL STATE SYNCHRONIZATION ===
|
||||
// Effect: URL note parameter → selectNote()
|
||||
// Only trigger if note ID changes and is different from current
|
||||
effect(() => {
|
||||
const urlNote = this.urlState.currentNote();
|
||||
if (urlNote && urlNote.id !== this.selectedNoteId()) {
|
||||
this.selectNote(urlNote.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Effect: URL tag parameter → handleTagClick()
|
||||
// Only trigger if tag changes and is different from current search term
|
||||
effect(() => {
|
||||
const urlTag = this.urlState.activeTag();
|
||||
const currentSearch = this.sidebarSearchTerm();
|
||||
const expectedSearch = urlTag ? `tag:${urlTag}` : '';
|
||||
if (urlTag && currentSearch !== expectedSearch) {
|
||||
this.handleTagClick(urlTag);
|
||||
}
|
||||
});
|
||||
|
||||
// Effect: URL search parameter → sidebarSearchTerm
|
||||
// Only trigger if search changes and is different from current
|
||||
effect(() => {
|
||||
const urlSearch = this.urlState.activeSearch();
|
||||
if (urlSearch !== null && this.sidebarSearchTerm() !== urlSearch) {
|
||||
this.sidebarSearchTerm.set(urlSearch);
|
||||
}
|
||||
});
|
||||
|
||||
// Effect: Re-evaluate URL state when vault is loaded
|
||||
// This ensures URL parameters are processed after vault initialization
|
||||
effect(() => {
|
||||
const notes = this.vaultService.allNotes();
|
||||
if (notes.length > 0) {
|
||||
// Force re-evaluation of URL state
|
||||
const currentNote = this.urlState.currentNote();
|
||||
const currentTag = this.urlState.activeTag();
|
||||
const currentSearch = this.urlState.activeSearch();
|
||||
|
||||
// Trigger URL note effect if URL contains note parameter
|
||||
if (currentNote && currentNote.id !== this.selectedNoteId()) {
|
||||
this.selectNote(currentNote.id);
|
||||
}
|
||||
|
||||
// Trigger URL tag effect if URL contains tag parameter
|
||||
if (currentTag) {
|
||||
const currentSearchTerm = this.sidebarSearchTerm();
|
||||
const expectedSearch = `tag:${currentTag}`;
|
||||
if (currentSearchTerm !== expectedSearch) {
|
||||
this.handleTagClick(currentTag);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger URL search effect if URL contains search parameter
|
||||
if (currentSearch !== null && this.sidebarSearchTerm() !== currentSearch) {
|
||||
this.sidebarSearchTerm.set(currentSearch);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -946,6 +1009,9 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
if (!this.isDesktopView() && this.activeView() === 'search') {
|
||||
this.isSidebarOpen.set(false);
|
||||
}
|
||||
|
||||
// Sync with URL state
|
||||
this.urlState.openNote(note.filePath);
|
||||
}
|
||||
|
||||
selectNoteFromGraph(noteId: string): void {
|
||||
@@ -963,6 +1029,9 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
this.activeView.set('search');
|
||||
// Use Meilisearch-friendly syntax
|
||||
this.sidebarSearchTerm.set(`tag:${normalized}`);
|
||||
|
||||
// Sync with URL state
|
||||
this.urlState.filterByTag(normalized);
|
||||
}
|
||||
|
||||
updateSearchTerm(term: string, focusSearch = false): void {
|
||||
@@ -970,6 +1039,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
if (focusSearch || (term && term.trim().length > 0)) {
|
||||
this.activeView.set('search');
|
||||
}
|
||||
// Sync with URL state
|
||||
this.urlState.updateSearch(term ?? '');
|
||||
}
|
||||
|
||||
onSearchSubmit(query: string): void {
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
/**
|
||||
* EXEMPLES D'INTÉGRATION DU UrlStateService
|
||||
*
|
||||
* Ce fichier contient des exemples complets d'intégration du UrlStateService
|
||||
* dans les différents composants de l'application.
|
||||
*
|
||||
* À adapter selon votre structure de composants.
|
||||
*/
|
||||
|
||||
import { Component, inject, effect, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { UrlStateService } from '../services/url-state.service';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
import type { Note } from '../../types';
|
||||
|
||||
// ============================================================================
|
||||
// EXEMPLE 1: NotesListComponent avec synchronisation d'URL
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Exemple d'intégration dans NotesListComponent
|
||||
* Synchronise les filtres (tag, folder, quick link) avec l'URL
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-notes-list-with-url-state',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="notes-list-container">
|
||||
<!-- Afficher le filtre actif depuis l'URL -->
|
||||
<div *ngIf="urlState.activeTag() as tag" class="filter-badge">
|
||||
Tag: #{{ tag }}
|
||||
<button (click)="clearFilter()">✕</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="urlState.activeFolder() as folder" class="filter-badge">
|
||||
Folder: {{ folder }}
|
||||
<button (click)="clearFilter()">✕</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="urlState.activeQuickLink() as quick" class="filter-badge">
|
||||
{{ quick }}
|
||||
<button (click)="clearFilter()">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste des notes -->
|
||||
<ul class="notes">
|
||||
<li *ngFor="let note of filteredNotes()"
|
||||
[class.active]="urlState.isNoteOpen(note.filePath)"
|
||||
(click)="selectNote(note)">
|
||||
{{ note.title }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.filter-badge {
|
||||
padding: 8px 12px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.notes {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notes li {
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.notes li:hover {
|
||||
background: var(--surface1);
|
||||
}
|
||||
|
||||
.notes li.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class NotesListWithUrlStateExample {
|
||||
urlState = inject(UrlStateService);
|
||||
vault = inject(VaultService);
|
||||
|
||||
// Signaux dérivés de l'URL
|
||||
activeTag = this.urlState.activeTag;
|
||||
activeFolder = this.urlState.activeFolder;
|
||||
activeQuickLink = this.urlState.activeQuickLink;
|
||||
|
||||
// Notes filtrées basées sur l'état de l'URL
|
||||
filteredNotes = computed(() => {
|
||||
const allNotes = this.vault.allNotes();
|
||||
const tag = this.activeTag();
|
||||
const folder = this.activeFolder();
|
||||
|
||||
return allNotes.filter(note => {
|
||||
if (tag && !note.tags?.includes(tag)) return false;
|
||||
if (folder && !note.filePath?.startsWith(folder)) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Écouter les changements d'état
|
||||
effect(() => {
|
||||
const state = this.urlState.currentState();
|
||||
console.log('État actuel:', state);
|
||||
|
||||
// Réagir aux changements
|
||||
if (state.tag) {
|
||||
console.log('Filtre par tag:', state.tag);
|
||||
}
|
||||
if (state.folder) {
|
||||
console.log('Filtre par dossier:', state.folder);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sélectionner une note et mettre à jour l'URL
|
||||
selectNote(note: Note): void {
|
||||
this.urlState.openNote(note.filePath);
|
||||
}
|
||||
|
||||
// Effacer le filtre
|
||||
clearFilter(): void {
|
||||
this.urlState.resetState();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXEMPLE 2: NoteViewComponent avec chargement depuis l'URL
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Exemple d'intégration dans NoteViewComponent
|
||||
* Charge la note depuis l'URL et affiche son contenu
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-note-view-with-url-state',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="note-view-container">
|
||||
<!-- Afficher la note ouverte -->
|
||||
<div *ngIf="currentNote() as note" class="note-content">
|
||||
<h1>{{ note.title }}</h1>
|
||||
<div class="note-meta">
|
||||
<span>Chemin: {{ note.filePath }}</span>
|
||||
<span>Tags: {{ note.tags?.join(', ') }}</span>
|
||||
</div>
|
||||
<div class="note-body" [innerHTML]="note.content"></div>
|
||||
</div>
|
||||
|
||||
<!-- État de chargement -->
|
||||
<div *ngIf="!currentNote() && urlState.currentState().note" class="loading">
|
||||
Chargement de la note...
|
||||
</div>
|
||||
|
||||
<!-- Aucune note sélectionnée -->
|
||||
<div *ngIf="!currentNote() && !urlState.currentState().note" class="empty">
|
||||
Sélectionnez une note pour la lire
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.note-view-container {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.note-content h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.note-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.note-body {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--muted);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class NoteViewWithUrlStateExample {
|
||||
urlState = inject(UrlStateService);
|
||||
vault = inject(VaultService);
|
||||
|
||||
// Signal de la note actuelle depuis l'URL
|
||||
currentNote = this.urlState.currentNote;
|
||||
|
||||
constructor() {
|
||||
// Charger la note quand l'URL change
|
||||
effect(async () => {
|
||||
const note = this.currentNote();
|
||||
if (note) {
|
||||
// Charger le contenu complet si nécessaire
|
||||
await this.vault.ensureNoteLoadedByPath(note.filePath);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXEMPLE 3: TagsComponent avec synchronisation d'URL
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Exemple d'intégration dans TagsComponent
|
||||
* Synchronise la sélection de tags avec l'URL
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-tags-with-url-state',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="tags-container">
|
||||
<h3>Tags</h3>
|
||||
<div class="tags-list">
|
||||
<button *ngFor="let tag of vault.tags()"
|
||||
[class.active]="urlState.isTagActive(tag.name)"
|
||||
(click)="selectTag(tag.name)"
|
||||
class="tag-button">
|
||||
#{{ tag.name }} ({{ tag.count }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.tags-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tag-button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.tag-button:hover {
|
||||
background: var(--surface1);
|
||||
}
|
||||
|
||||
.tag-button.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class TagsWithUrlStateExample {
|
||||
urlState = inject(UrlStateService);
|
||||
vault = inject(VaultService);
|
||||
|
||||
selectTag(tagName: string): void {
|
||||
this.urlState.filterByTag(tagName);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXEMPLE 4: FoldersSidebarComponent avec synchronisation d'URL
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Exemple d'intégration dans FoldersSidebarComponent
|
||||
* Synchronise la sélection de dossiers avec l'URL
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-folders-with-url-state',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="folders-container">
|
||||
<h3>Dossiers</h3>
|
||||
<div class="folders-tree">
|
||||
<button *ngFor="let folder of vault.fileTree()"
|
||||
[class.active]="urlState.isFolderActive(folder.path)"
|
||||
(click)="selectFolder(folder.path)"
|
||||
class="folder-button">
|
||||
📁 {{ folder.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.folders-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.folders-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.folder-button {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.folder-button:hover {
|
||||
background: var(--surface1);
|
||||
}
|
||||
|
||||
.folder-button.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class FoldersWithUrlStateExample {
|
||||
urlState = inject(UrlStateService);
|
||||
vault = inject(VaultService);
|
||||
|
||||
selectFolder(folderPath: string): void {
|
||||
this.urlState.filterByFolder(folderPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXEMPLE 5: SearchComponent avec synchronisation d'URL
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Exemple d'intégration dans SearchComponent
|
||||
* Synchronise la recherche avec l'URL
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-search-with-url-state',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="search-container">
|
||||
<input type="text"
|
||||
[value]="urlState.activeSearch() || ''"
|
||||
(input)="onSearch($event)"
|
||||
placeholder="Rechercher..."
|
||||
class="search-input" />
|
||||
|
||||
<!-- Résultats de recherche -->
|
||||
<div *ngIf="searchResults() as results" class="search-results">
|
||||
<div *ngIf="results.length === 0" class="no-results">
|
||||
Aucun résultat
|
||||
</div>
|
||||
<ul *ngIf="results.length > 0">
|
||||
<li *ngFor="let note of results"
|
||||
(click)="selectNote(note)">
|
||||
{{ note.title }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.search-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
margin-top: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-results ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-results li {
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.search-results li:hover {
|
||||
background: var(--surface1);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SearchWithUrlStateExample {
|
||||
urlState = inject(UrlStateService);
|
||||
vault = inject(VaultService);
|
||||
|
||||
searchResults = computed(() => {
|
||||
const searchTerm = this.urlState.activeSearch();
|
||||
if (!searchTerm) return [];
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
return this.vault.allNotes().filter(note =>
|
||||
note.title.toLowerCase().includes(term) ||
|
||||
note.content?.toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
|
||||
onSearch(event: Event): void {
|
||||
const searchTerm = (event.target as HTMLInputElement).value;
|
||||
this.urlState.updateSearch(searchTerm);
|
||||
}
|
||||
|
||||
selectNote(note: Note): void {
|
||||
this.urlState.openNote(note.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXEMPLE 6: Partage de lien
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Exemple d'intégration pour le partage de lien
|
||||
* Génère et copie une URL partageble
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-share-button-with-url-state',
|
||||
standalone: true,
|
||||
template: `
|
||||
<button (click)="shareCurrentState()" class="share-button">
|
||||
📤 Partager
|
||||
</button>
|
||||
`,
|
||||
styles: [`
|
||||
.share-button {
|
||||
padding: 8px 16px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.share-button:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ShareButtonWithUrlStateExample {
|
||||
urlState = inject(UrlStateService);
|
||||
|
||||
async shareCurrentState(): Promise<void> {
|
||||
try {
|
||||
await this.urlState.copyCurrentUrlToClipboard();
|
||||
console.log('Lien copié!');
|
||||
// Afficher un toast de confirmation
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la copie:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXEMPLE 7: Historique de navigation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Exemple d'intégration pour l'historique de navigation
|
||||
* Permet de revenir à l'état précédent
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-navigation-history-with-url-state',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div class="navigation-controls">
|
||||
<button (click)="goBack()" [disabled]="!canGoBack()" class="nav-button">
|
||||
← Retour
|
||||
</button>
|
||||
|
||||
<button (click)="reset()" class="nav-button">
|
||||
🏠 Accueil
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.navigation-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-button:hover:not(:disabled) {
|
||||
background: var(--surface1);
|
||||
}
|
||||
|
||||
.nav-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class NavigationHistoryWithUrlStateExample {
|
||||
urlState = inject(UrlStateService);
|
||||
|
||||
canGoBack = computed(() => {
|
||||
const previous = this.urlState.previousState();
|
||||
const current = this.urlState.currentState();
|
||||
return JSON.stringify(previous) !== JSON.stringify(current);
|
||||
});
|
||||
|
||||
goBack(): void {
|
||||
const previousState = this.urlState.getPreviousState();
|
||||
|
||||
if (previousState.note) {
|
||||
this.urlState.openNote(previousState.note);
|
||||
} else if (previousState.tag) {
|
||||
this.urlState.filterByTag(previousState.tag);
|
||||
} else if (previousState.folder) {
|
||||
this.urlState.filterByFolder(previousState.folder);
|
||||
} else {
|
||||
this.urlState.resetState();
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.urlState.resetState();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, Input, Output, ViewChild, inject } from '@angular/core';
|
||||
import { Component, EventEmitter, Input, Output, ViewChild, inject, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { FileExplorerComponent } from '../../../components/file-explorer/file-explorer.component';
|
||||
@@ -163,12 +163,13 @@ import { VaultService } from '../../../services/vault.service';
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class NimbusSidebarComponent {
|
||||
export class NimbusSidebarComponent implements OnChanges {
|
||||
@Input() vaultName = '';
|
||||
@Input() effectiveFileTree: VaultNode[] = [];
|
||||
@Input() selectedNoteId: string | null = null;
|
||||
@Input() tags: TagInfo[] = [];
|
||||
@Input() quickLinkFilter: 'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null = null;
|
||||
@Input() forceOpenSection: 'folders' | 'tags' | 'quick' | null = null;
|
||||
|
||||
@Output() toggleSidebarRequest = new EventEmitter<void>();
|
||||
@Output() folderSelected = new EventEmitter<string>();
|
||||
@@ -185,6 +186,19 @@ export class NimbusSidebarComponent {
|
||||
private vault = inject(VaultService);
|
||||
@ViewChild('foldersExplorer') private foldersExplorer?: FileExplorerComponent;
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['forceOpenSection']) {
|
||||
const which = this.forceOpenSection;
|
||||
if (which === 'folders') {
|
||||
this.open = { quick: false, folders: true, tags: false, trash: false, tests: false };
|
||||
} else if (which === 'tags') {
|
||||
this.open = { quick: false, folders: false, tags: true, trash: false, tests: false };
|
||||
} else if (which === 'quick') {
|
||||
this.open = { quick: true, folders: false, tags: false, trash: false, tests: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onQuickLink(id: string) { this.quickLinkSelected.emit(id); }
|
||||
|
||||
onMarkdownPlaygroundClick(): void {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
|
||||
import { TestsPanelComponent } from '../../features/tests/tests-panel.component';
|
||||
import { ParametersPage } from '../../features/parameters/parameters.page';
|
||||
import { AboutPanelComponent } from '../../features/about/about-panel.component';
|
||||
import { UrlStateService } from '../../services/url-state.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shell-nimbus-layout',
|
||||
@@ -59,6 +60,7 @@ import { AboutPanelComponent } from '../../features/about/about-panel.component'
|
||||
[selectedNoteId]="selectedNoteId"
|
||||
[tags]="tags"
|
||||
[quickLinkFilter]="quickLinkFilter"
|
||||
[forceOpenSection]="tagFilter ? 'tags' : (folderFilter ? 'folders' : (quickLinkFilter ? 'quick' : null))"
|
||||
(toggleSidebarRequest)="toggleSidebarRequest.emit()"
|
||||
(folderSelected)="onFolderSelected($event)"
|
||||
(fileSelected)="noteSelected.emit($event)"
|
||||
@@ -335,6 +337,7 @@ export class AppShellNimbusLayoutComponent {
|
||||
vault = inject(VaultService);
|
||||
responsive = inject(ResponsiveService);
|
||||
mobileNav = inject(MobileNavService);
|
||||
urlState = inject(UrlStateService);
|
||||
|
||||
noteFullScreen = false;
|
||||
showAboutPanel = false;
|
||||
@@ -377,6 +380,152 @@ export class AppShellNimbusLayoutComponent {
|
||||
tagFilter: string | null = null;
|
||||
quickLinkFilter: 'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null = null;
|
||||
|
||||
// --- 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 (!hasNote) this.autoSelectFirstNote();
|
||||
if (!this.responsive.isDesktop()) this.mobileNav.setActiveTab('list');
|
||||
}
|
||||
// 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');
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
// 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();
|
||||
@@ -445,6 +594,8 @@ export class AppShellNimbusLayoutComponent {
|
||||
onQueryChange(query: string) {
|
||||
this.listQuery = query;
|
||||
this.autoSelectFirstNote();
|
||||
// Sync URL search term
|
||||
this.urlState.updateSearch(query);
|
||||
}
|
||||
|
||||
toggleNoteFullScreen(): void {
|
||||
@@ -468,6 +619,11 @@ export class AppShellNimbusLayoutComponent {
|
||||
|
||||
onOpenNote(noteId: string) {
|
||||
this.noteSelected.emit(noteId);
|
||||
// Update URL with selected note path
|
||||
const n = (this.vault.allNotes() || []).find(x => x.id === noteId);
|
||||
if (n && n.filePath) {
|
||||
this.urlState.openNote(n.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
onNoteSelectedMobile(noteId: string) {
|
||||
@@ -483,6 +639,12 @@ export class AppShellNimbusLayoutComponent {
|
||||
if (this.responsive.isMobile() || this.responsive.isTablet()) {
|
||||
this.mobileNav.setActiveTab('list');
|
||||
}
|
||||
// Reflect folder in URL
|
||||
if (path) {
|
||||
this.urlState.filterByFolder(path);
|
||||
} else {
|
||||
this.urlState.resetState();
|
||||
}
|
||||
}
|
||||
|
||||
onFolderSelectedFromDrawer(path: string) {
|
||||
@@ -491,6 +653,11 @@ export class AppShellNimbusLayoutComponent {
|
||||
this.autoSelectFirstNote();
|
||||
this.mobileNav.setActiveTab('list');
|
||||
this.mobileNav.sidebarOpen.set(false);
|
||||
if (path) {
|
||||
this.urlState.filterByFolder(path);
|
||||
} else {
|
||||
this.urlState.resetState();
|
||||
}
|
||||
}
|
||||
|
||||
onQuickLink(_id: string) {
|
||||
@@ -504,6 +671,7 @@ export class AppShellNimbusLayoutComponent {
|
||||
this.mobileNav.setActiveTab('list');
|
||||
}
|
||||
this.scheduleCloseFlyout(150);
|
||||
this.urlState.resetState();
|
||||
} else if (_id === 'publish') {
|
||||
// Filter by publish: true
|
||||
this.folderFilter = null;
|
||||
@@ -514,6 +682,8 @@ export class AppShellNimbusLayoutComponent {
|
||||
this.mobileNav.setActiveTab('list');
|
||||
}
|
||||
this.scheduleCloseFlyout(150);
|
||||
const label = this.mapInternalQuickToUrl('publish');
|
||||
if (label) this.urlState.filterByQuickLink(label);
|
||||
} else if (_id === 'favorites') {
|
||||
// Filter by favoris: true
|
||||
this.folderFilter = null;
|
||||
@@ -524,6 +694,8 @@ export class AppShellNimbusLayoutComponent {
|
||||
this.mobileNav.setActiveTab('list');
|
||||
}
|
||||
this.scheduleCloseFlyout(150);
|
||||
const label = this.mapInternalQuickToUrl('favoris');
|
||||
if (label) this.urlState.filterByQuickLink(label);
|
||||
} else if (_id === 'templates') {
|
||||
// Filter by template: true
|
||||
this.folderFilter = null;
|
||||
@@ -534,6 +706,8 @@ export class AppShellNimbusLayoutComponent {
|
||||
this.mobileNav.setActiveTab('list');
|
||||
}
|
||||
this.scheduleCloseFlyout(150);
|
||||
const label = this.mapInternalQuickToUrl('template');
|
||||
if (label) this.urlState.filterByQuickLink(label);
|
||||
} else if (_id === 'tasks') {
|
||||
// Filter by task: true
|
||||
this.folderFilter = null;
|
||||
@@ -544,6 +718,8 @@ export class AppShellNimbusLayoutComponent {
|
||||
this.mobileNav.setActiveTab('list');
|
||||
}
|
||||
this.scheduleCloseFlyout(150);
|
||||
const label = this.mapInternalQuickToUrl('task');
|
||||
if (label) this.urlState.filterByQuickLink(label);
|
||||
} else if (_id === 'drafts') {
|
||||
// Filter by draft: true
|
||||
this.folderFilter = null;
|
||||
@@ -554,6 +730,8 @@ export class AppShellNimbusLayoutComponent {
|
||||
this.mobileNav.setActiveTab('list');
|
||||
}
|
||||
this.scheduleCloseFlyout(150);
|
||||
const label = this.mapInternalQuickToUrl('draft');
|
||||
if (label) this.urlState.filterByQuickLink(label);
|
||||
} else if (_id === 'private') {
|
||||
// Filter by private: true
|
||||
this.folderFilter = null;
|
||||
@@ -564,6 +742,8 @@ export class AppShellNimbusLayoutComponent {
|
||||
this.mobileNav.setActiveTab('list');
|
||||
}
|
||||
this.scheduleCloseFlyout(150);
|
||||
const label = this.mapInternalQuickToUrl('private');
|
||||
if (label) this.urlState.filterByQuickLink(label);
|
||||
} else if (_id === 'archive') {
|
||||
// Filter by archive: true
|
||||
this.folderFilter = null;
|
||||
@@ -574,6 +754,8 @@ export class AppShellNimbusLayoutComponent {
|
||||
this.mobileNav.setActiveTab('list');
|
||||
}
|
||||
this.scheduleCloseFlyout(150);
|
||||
const label = this.mapInternalQuickToUrl('archive');
|
||||
if (label) this.urlState.filterByQuickLink(label);
|
||||
}
|
||||
// Auto-select first note after filter changes
|
||||
this.autoSelectFirstNote();
|
||||
@@ -601,6 +783,10 @@ export class AppShellNimbusLayoutComponent {
|
||||
}
|
||||
// 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') {
|
||||
|
||||
@@ -29,6 +29,10 @@ export class NoteContextMenuService {
|
||||
}
|
||||
|
||||
// Actions du menu
|
||||
private getSanitizedId(note: Note): string {
|
||||
const id = note.id || note.filePath || '';
|
||||
return id.replace(/\\/g, '/').replace(/\.md$/i, '');
|
||||
}
|
||||
async duplicateNote(note: Note): Promise<void> {
|
||||
try {
|
||||
const folderPath = note.filePath.split('/').slice(0, -1).join('/');
|
||||
@@ -88,9 +92,8 @@ export class NoteContextMenuService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Générer l'URL de partage
|
||||
const encodedPath = encodeURIComponent(note.filePath);
|
||||
const shareUrl = `${window.location.origin}/#/note/${encodedPath}`;
|
||||
// Générer l'URL de partage (utilise l'état URL ?note=...)
|
||||
const shareUrl = `${window.location.origin}/?note=${encodeURIComponent(note.filePath)}`;
|
||||
|
||||
// Copier dans le presse-papiers
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
@@ -105,33 +108,20 @@ export class NoteContextMenuService {
|
||||
}
|
||||
|
||||
openFullScreen(note: Note): void {
|
||||
const encodedPath = encodeURIComponent(note.filePath);
|
||||
const fullScreenUrl = `#/note/${encodedPath}?view=full`;
|
||||
|
||||
// Naviguer vers l'URL plein écran
|
||||
window.location.hash = fullScreenUrl;
|
||||
|
||||
this.toast.info('Mode plein écran activé (Échap pour quitter)');
|
||||
// Ouvre la note en plein écran via les query params
|
||||
const url = `/?note=${encodeURIComponent(note.filePath)}&view=full`;
|
||||
window.location.assign(url);
|
||||
this.emitEvent('noteOpenedFull', { path: note.filePath });
|
||||
}
|
||||
|
||||
async copyInternalLink(note: Note): Promise<void> {
|
||||
try {
|
||||
// Format du lien interne Obsidian
|
||||
let link: string;
|
||||
|
||||
if (note.frontmatter?.aliases && note.frontmatter.aliases.length > 0) {
|
||||
// Utiliser le premier alias si disponible
|
||||
link = `[[${note.filePath}|${note.frontmatter.aliases[0]}]]`;
|
||||
} else {
|
||||
// Utiliser le titre
|
||||
link = `[[${note.filePath}|${note.title}]]`;
|
||||
}
|
||||
// Copier l'URL complète vers la note
|
||||
const url = `${window.location.origin}/?note=${encodeURIComponent(note.filePath)}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
this.toast.success('URL copiée dans le presse-papiers');
|
||||
|
||||
await navigator.clipboard.writeText(link);
|
||||
this.toast.success('Lien interne copié');
|
||||
|
||||
this.emitEvent('noteCopiedLink', { path: note.filePath, link });
|
||||
this.emitEvent('noteCopiedLink', { path: note.filePath, link: url });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Copy internal link error:', error);
|
||||
@@ -143,13 +133,12 @@ export class NoteContextMenuService {
|
||||
try {
|
||||
const isFavorite = !note.frontmatter?.favoris;
|
||||
|
||||
// Mettre à jour le frontmatter
|
||||
const response = await fetch(`/api/vault/notes/${note.id}/frontmatter`, {
|
||||
// Mettre à jour le frontmatter (endpoint PATCH /api/vault/notes/:id)
|
||||
const noteId = this.getSanitizedId(note);
|
||||
const response = await fetch(`/api/vault/notes/${noteId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
favoris: isFavorite
|
||||
})
|
||||
body: JSON.stringify({ frontmatter: { favoris: isFavorite } })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -206,13 +195,12 @@ ${note.frontmatter?.favoris ? '⭐ Favori' : ''}
|
||||
try {
|
||||
const isReadOnly = !note.frontmatter?.readOnly;
|
||||
|
||||
// Mettre à jour le frontmatter
|
||||
const response = await fetch(`/api/vault/notes/${note.id}/frontmatter`, {
|
||||
// Mettre à jour le frontmatter (endpoint PATCH /api/vault/notes/:id)
|
||||
const noteId = this.getSanitizedId(note);
|
||||
const response = await fetch(`/api/vault/notes/${noteId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
readOnly: isReadOnly
|
||||
})
|
||||
body: JSON.stringify({ frontmatter: { readOnly: isReadOnly } })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -245,7 +233,8 @@ ${note.frontmatter?.favoris ? '⭐ Favori' : ''}
|
||||
if (!confirmed) return;
|
||||
|
||||
// Déplacer vers la corbeille
|
||||
const response = await fetch(`/api/vault/notes/${note.id}`, {
|
||||
const noteId = this.getSanitizedId(note);
|
||||
const response = await fetch(`/api/vault/notes/${noteId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
@@ -268,13 +257,12 @@ ${note.frontmatter?.favoris ? '⭐ Favori' : ''}
|
||||
|
||||
async changeNoteColor(note: Note, color: string): Promise<void> {
|
||||
try {
|
||||
// Mettre à jour le frontmatter
|
||||
const response = await fetch(`/api/vault/notes/${note.id}/frontmatter`, {
|
||||
// Mettre à jour le frontmatter (endpoint PATCH /api/vault/notes/:id)
|
||||
const noteId = this.getSanitizedId(note);
|
||||
const response = await fetch(`/api/vault/notes/${noteId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
color: color || null // null pour supprimer la couleur
|
||||
})
|
||||
body: JSON.stringify({ frontmatter: { color: color || null } })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router, ActivatedRoute, NavigationEnd } from '@angular/router';
|
||||
import { UrlStateService, UrlState } from './url-state.service';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
describe('UrlStateService', () => {
|
||||
let service: UrlStateService;
|
||||
let router: jasmine.SpyObj<Router>;
|
||||
let activatedRoute: jasmine.SpyObj<ActivatedRoute>;
|
||||
let vaultService: jasmine.SpyObj<VaultService>;
|
||||
let routerEventsSubject: Subject<any>;
|
||||
let queryParamsSubject: Subject<any>;
|
||||
|
||||
beforeEach(() => {
|
||||
routerEventsSubject = new Subject();
|
||||
queryParamsSubject = new Subject();
|
||||
|
||||
const routerSpy = jasmine.createSpyObj('Router', ['navigate'], {
|
||||
events: routerEventsSubject.asObservable()
|
||||
});
|
||||
|
||||
const activatedRouteSpy = jasmine.createSpyObj('ActivatedRoute', [], {
|
||||
queryParams: queryParamsSubject.asObservable()
|
||||
});
|
||||
|
||||
const vaultServiceSpy = jasmine.createSpyObj('VaultService', [], {
|
||||
allNotes: jasmine.createSpy('allNotes').and.returnValue([
|
||||
{
|
||||
id: '1',
|
||||
title: 'Note 1',
|
||||
filePath: 'Docs/Architecture.md',
|
||||
tags: ['Ideas'],
|
||||
content: 'Content 1'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Note 2',
|
||||
filePath: 'Notes/Meetings/Meeting1.md',
|
||||
tags: ['Important'],
|
||||
content: 'Content 2'
|
||||
}
|
||||
]),
|
||||
tags: jasmine.createSpy('tags').and.returnValue([
|
||||
{ name: 'Ideas', count: 5 },
|
||||
{ name: 'Important', count: 3 }
|
||||
]),
|
||||
fileTree: jasmine.createSpy('fileTree').and.returnValue([
|
||||
{
|
||||
type: 'folder',
|
||||
name: 'Docs',
|
||||
path: 'Docs',
|
||||
children: [
|
||||
{ type: 'file', name: 'Architecture.md', path: 'Docs/Architecture.md' }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
name: 'Notes',
|
||||
path: 'Notes',
|
||||
children: [
|
||||
{
|
||||
type: 'folder',
|
||||
name: 'Meetings',
|
||||
path: 'Notes/Meetings',
|
||||
children: [
|
||||
{ type: 'file', name: 'Meeting1.md', path: 'Notes/Meetings/Meeting1.md' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
UrlStateService,
|
||||
{ provide: Router, useValue: routerSpy },
|
||||
{ provide: ActivatedRoute, useValue: activatedRouteSpy },
|
||||
{ provide: VaultService, useValue: vaultServiceSpy }
|
||||
]
|
||||
});
|
||||
|
||||
service = TestBed.inject(UrlStateService);
|
||||
router = TestBed.inject(Router) as jasmine.SpyObj<Router>;
|
||||
activatedRoute = TestBed.inject(ActivatedRoute) as jasmine.SpyObj<ActivatedRoute>;
|
||||
vaultService = TestBed.inject(VaultService) as jasmine.SpyObj<VaultService>;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.ngOnDestroy();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should initialize with empty state', () => {
|
||||
queryParamsSubject.next({});
|
||||
expect(service.currentState()).toEqual({});
|
||||
});
|
||||
|
||||
it('should parse URL params on initialization', () => {
|
||||
queryParamsSubject.next({
|
||||
note: 'Docs/Architecture.md',
|
||||
tag: 'Ideas'
|
||||
});
|
||||
|
||||
expect(service.currentState().note).toBe('Docs/Architecture.md');
|
||||
expect(service.currentState().tag).toBe('Ideas');
|
||||
});
|
||||
|
||||
it('should decode URI components', () => {
|
||||
queryParamsSubject.next({
|
||||
note: encodeURIComponent('Docs/My Note.md'),
|
||||
search: encodeURIComponent('performance test')
|
||||
});
|
||||
|
||||
expect(service.currentState().note).toBe('Docs/My Note.md');
|
||||
expect(service.currentState().search).toBe('performance test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Computed Signals', () => {
|
||||
it('should compute currentNote from URL', () => {
|
||||
queryParamsSubject.next({ note: 'Docs/Architecture.md' });
|
||||
expect(service.currentNote()?.title).toBe('Note 1');
|
||||
});
|
||||
|
||||
it('should return null for non-existent note', () => {
|
||||
queryParamsSubject.next({ note: 'NonExistent.md' });
|
||||
expect(service.currentNote()).toBeNull();
|
||||
});
|
||||
|
||||
it('should compute activeTag from URL', () => {
|
||||
queryParamsSubject.next({ tag: 'Ideas' });
|
||||
expect(service.activeTag()).toBe('Ideas');
|
||||
});
|
||||
|
||||
it('should compute activeFolder from URL', () => {
|
||||
queryParamsSubject.next({ folder: 'Docs' });
|
||||
expect(service.activeFolder()).toBe('Docs');
|
||||
});
|
||||
|
||||
it('should compute activeQuickLink from URL', () => {
|
||||
queryParamsSubject.next({ quick: 'Favoris' });
|
||||
expect(service.activeQuickLink()).toBe('Favoris');
|
||||
});
|
||||
|
||||
it('should compute activeSearch from URL', () => {
|
||||
queryParamsSubject.next({ search: 'performance' });
|
||||
expect(service.activeSearch()).toBe('performance');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation Methods', () => {
|
||||
it('should open note and update URL', async () => {
|
||||
await service.openNote('Docs/Architecture.md');
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
[],
|
||||
jasmine.objectContaining({
|
||||
queryParams: { note: 'Docs/Architecture.md' }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn when opening non-existent note', async () => {
|
||||
spyOn(console, 'warn');
|
||||
await service.openNote('NonExistent.md');
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith('Note not found: NonExistent.md');
|
||||
});
|
||||
|
||||
it('should filter by tag', async () => {
|
||||
await service.filterByTag('Ideas');
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
[],
|
||||
jasmine.objectContaining({
|
||||
queryParams: jasmine.objectContaining({ tag: 'Ideas' })
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn when filtering by non-existent tag', async () => {
|
||||
spyOn(console, 'warn');
|
||||
await service.filterByTag('NonExistent');
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith('Tag not found: NonExistent');
|
||||
});
|
||||
|
||||
it('should filter by folder', async () => {
|
||||
await service.filterByFolder('Docs');
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
[],
|
||||
jasmine.objectContaining({
|
||||
queryParams: jasmine.objectContaining({ folder: 'Docs' })
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn when filtering by non-existent folder', async () => {
|
||||
spyOn(console, 'warn');
|
||||
await service.filterByFolder('NonExistent');
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith('Folder not found: NonExistent');
|
||||
});
|
||||
|
||||
it('should filter by quick link', async () => {
|
||||
await service.filterByQuickLink('Favoris');
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
[],
|
||||
jasmine.objectContaining({
|
||||
queryParams: jasmine.objectContaining({ quick: 'Favoris' })
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn when filtering by invalid quick link', async () => {
|
||||
spyOn(console, 'warn');
|
||||
await service.filterByQuickLink('Invalid');
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith('Invalid quick link: Invalid');
|
||||
});
|
||||
|
||||
it('should update search', async () => {
|
||||
await service.updateSearch('performance');
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
[],
|
||||
jasmine.objectContaining({
|
||||
queryParams: jasmine.objectContaining({ search: 'performance' })
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should reset state', async () => {
|
||||
await service.resetState();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
[],
|
||||
jasmine.objectContaining({
|
||||
queryParams: {}
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Checking Methods', () => {
|
||||
it('should check if note is open', () => {
|
||||
queryParamsSubject.next({ note: 'Docs/Architecture.md' });
|
||||
expect(service.isNoteOpen('Docs/Architecture.md')).toBe(true);
|
||||
expect(service.isNoteOpen('Other.md')).toBe(false);
|
||||
});
|
||||
|
||||
it('should check if tag is active', () => {
|
||||
queryParamsSubject.next({ tag: 'Ideas' });
|
||||
expect(service.isTagActive('Ideas')).toBe(true);
|
||||
expect(service.isTagActive('Other')).toBe(false);
|
||||
});
|
||||
|
||||
it('should check if folder is active', () => {
|
||||
queryParamsSubject.next({ folder: 'Docs' });
|
||||
expect(service.isFolderActive('Docs')).toBe(true);
|
||||
expect(service.isFolderActive('Other')).toBe(false);
|
||||
});
|
||||
|
||||
it('should check if quick link is active', () => {
|
||||
queryParamsSubject.next({ quick: 'Favoris' });
|
||||
expect(service.isQuickLinkActive('Favoris')).toBe(true);
|
||||
expect(service.isQuickLinkActive('Other')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Share URL Methods', () => {
|
||||
it('should generate share URL with current state', () => {
|
||||
queryParamsSubject.next({ note: 'Docs/Architecture.md' });
|
||||
const url = service.generateShareUrl();
|
||||
|
||||
expect(url).toContain('note=Docs%2FArchitecture.md');
|
||||
});
|
||||
|
||||
it('should generate share URL with custom state', () => {
|
||||
queryParamsSubject.next({ note: 'Docs/Architecture.md' });
|
||||
const url = service.generateShareUrl({ tag: 'Ideas' });
|
||||
|
||||
expect(url).toContain('note=Docs%2FArchitecture.md');
|
||||
expect(url).toContain('tag=Ideas');
|
||||
});
|
||||
|
||||
it('should copy URL to clipboard', async () => {
|
||||
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
|
||||
|
||||
await service.copyCurrentUrlToClipboard();
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Getters', () => {
|
||||
it('should get current state', () => {
|
||||
queryParamsSubject.next({ note: 'Docs/Architecture.md', tag: 'Ideas' });
|
||||
const state = service.getState();
|
||||
|
||||
expect(state.note).toBe('Docs/Architecture.md');
|
||||
expect(state.tag).toBe('Ideas');
|
||||
});
|
||||
|
||||
it('should get previous state', () => {
|
||||
queryParamsSubject.next({ note: 'Docs/Architecture.md' });
|
||||
queryParamsSubject.next({ tag: 'Ideas' });
|
||||
|
||||
const previousState = service.getPreviousState();
|
||||
expect(previousState.note).toBe('Docs/Architecture.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Change Events', () => {
|
||||
it('should emit state change event', (done) => {
|
||||
service.stateChange$.subscribe(event => {
|
||||
expect(event.changed).toContain('note');
|
||||
expect(event.current.note).toBe('Docs/Architecture.md');
|
||||
done();
|
||||
});
|
||||
|
||||
queryParamsSubject.next({ note: 'Docs/Architecture.md' });
|
||||
});
|
||||
|
||||
it('should emit property-specific change event', (done) => {
|
||||
service.onStatePropertyChange('tag').subscribe(event => {
|
||||
expect(event.current.tag).toBe('Ideas');
|
||||
done();
|
||||
});
|
||||
|
||||
queryParamsSubject.next({ tag: 'Ideas' });
|
||||
});
|
||||
|
||||
it('should not emit event when state does not change', () => {
|
||||
let emitCount = 0;
|
||||
service.stateChange$.subscribe(() => emitCount++);
|
||||
|
||||
queryParamsSubject.next({ note: 'Docs/Architecture.md' });
|
||||
queryParamsSubject.next({ note: 'Docs/Architecture.md' });
|
||||
|
||||
expect(emitCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Transitions', () => {
|
||||
it('should transition from tag filter to folder filter', async () => {
|
||||
queryParamsSubject.next({ tag: 'Ideas' });
|
||||
expect(service.activeTag()).toBe('Ideas');
|
||||
|
||||
await service.filterByFolder('Docs');
|
||||
|
||||
expect(service.activeFolder()).toBe('Docs');
|
||||
expect(service.activeTag()).toBeNull();
|
||||
});
|
||||
|
||||
it('should transition from folder filter to note view', async () => {
|
||||
queryParamsSubject.next({ folder: 'Docs' });
|
||||
expect(service.activeFolder()).toBe('Docs');
|
||||
|
||||
await service.openNote('Docs/Architecture.md');
|
||||
|
||||
expect(service.currentNote()?.title).toBe('Note 1');
|
||||
expect(service.activeFolder()).toBeNull();
|
||||
});
|
||||
|
||||
it('should maintain search across transitions', async () => {
|
||||
queryParamsSubject.next({ search: 'performance', tag: 'Ideas' });
|
||||
expect(service.activeSearch()).toBe('performance');
|
||||
|
||||
await service.filterByFolder('Docs');
|
||||
|
||||
// Search should be cleared when changing filter
|
||||
expect(service.activeSearch()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty query params', () => {
|
||||
queryParamsSubject.next({});
|
||||
expect(service.currentState()).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle undefined values', () => {
|
||||
queryParamsSubject.next({ note: undefined, tag: undefined });
|
||||
expect(service.currentState().note).toBeUndefined();
|
||||
expect(service.currentState().tag).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle special characters in paths', () => {
|
||||
const path = 'Docs/My Note (2024).md';
|
||||
queryParamsSubject.next({ note: encodeURIComponent(path) });
|
||||
expect(service.currentState().note).toBe(path);
|
||||
});
|
||||
|
||||
it('should handle very long search terms', () => {
|
||||
const longSearch = 'a'.repeat(1000);
|
||||
queryParamsSubject.next({ search: longSearch });
|
||||
expect(service.activeSearch()).toBe(longSearch);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lifecycle', () => {
|
||||
it('should clean up on destroy', () => {
|
||||
const destroySpy = spyOn(service['destroy$'], 'next');
|
||||
service.ngOnDestroy();
|
||||
expect(destroySpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,472 @@
|
||||
import { Injectable, inject, signal, effect, computed, OnDestroy } from '@angular/core';
|
||||
import { Router, NavigationEnd } from '@angular/router';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
import { filter, takeUntil, map } from 'rxjs/operators';
|
||||
import { startWith } from 'rxjs';
|
||||
import { Subject } from 'rxjs';
|
||||
import type { Note } from '../../types';
|
||||
|
||||
/**
|
||||
* Types pour la gestion d'état d'URL
|
||||
*/
|
||||
export interface UrlState {
|
||||
note?: string; // Chemin de la note ouverte
|
||||
tag?: string; // Tag de filtrage
|
||||
folder?: string; // Dossier de filtrage
|
||||
quick?: string; // Quick link de filtrage
|
||||
search?: string; // Terme de recherche
|
||||
}
|
||||
|
||||
export interface UrlStateChangeEvent {
|
||||
previous: UrlState;
|
||||
current: UrlState;
|
||||
changed: (keyof UrlState)[];
|
||||
}
|
||||
|
||||
/**
|
||||
* UrlStateService
|
||||
*
|
||||
* Synchronise et restaure l'état de l'interface via l'URL.
|
||||
* Permet le deep-linking et le partage de liens.
|
||||
*
|
||||
* Exemples d'URL:
|
||||
* - /viewer?note=Docs/Architecture.md
|
||||
* - /viewer?tag=Ideas
|
||||
* - /viewer?folder=Notes/Meetings
|
||||
* - /viewer?quick=Archive
|
||||
* - /viewer?note=Docs/Architecture.md&search=performance
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UrlStateService implements OnDestroy {
|
||||
// ========================================
|
||||
// INJECTIONS
|
||||
// ========================================
|
||||
private router = inject(Router);
|
||||
private vaultService = inject(VaultService);
|
||||
|
||||
// ========================================
|
||||
// STATE SIGNALS
|
||||
// ========================================
|
||||
|
||||
// État actuel de l'URL
|
||||
private currentStateSignal = signal<UrlState>({});
|
||||
|
||||
// État précédent (pour détecter les changements)
|
||||
private previousStateSignal = signal<UrlState>({});
|
||||
|
||||
// Événement de changement d'état
|
||||
private stateChangeSubject = new Subject<UrlStateChangeEvent>();
|
||||
|
||||
// Gestion du cycle de vie
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
// ========================================
|
||||
// COMPUTED SIGNALS
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* État actuel de l'URL
|
||||
*/
|
||||
readonly currentState = computed(() => this.currentStateSignal());
|
||||
|
||||
/**
|
||||
* État précédent
|
||||
*/
|
||||
readonly previousState = computed(() => this.previousStateSignal());
|
||||
|
||||
/**
|
||||
* Note actuellement ouverte
|
||||
*/
|
||||
readonly currentNote = computed(() => {
|
||||
const notePath = this.currentStateSignal().note;
|
||||
const result = notePath ? this.vaultService.allNotes().find(n => n.filePath === notePath) || null : null;
|
||||
console.log('📄 currentNote():', result ? { id: result.id, title: result.title, filePath: result.filePath } : null);
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Tag actif
|
||||
*/
|
||||
readonly activeTag = computed(() => {
|
||||
const tag = this.currentStateSignal().tag || null;
|
||||
console.log('🏷️ activeTag():', tag);
|
||||
return tag;
|
||||
});
|
||||
|
||||
/**
|
||||
* Dossier actif
|
||||
*/
|
||||
readonly activeFolder = computed(() => {
|
||||
const folder = this.currentStateSignal().folder || null;
|
||||
console.log('📁 activeFolder():', folder);
|
||||
return folder;
|
||||
});
|
||||
|
||||
/**
|
||||
* Quick link actif
|
||||
*/
|
||||
readonly activeQuickLink = computed(() => {
|
||||
const quick = this.currentStateSignal().quick || null;
|
||||
console.log('⚡ activeQuickLink():', quick);
|
||||
return quick;
|
||||
});
|
||||
|
||||
/**
|
||||
* Terme de recherche actif
|
||||
*/
|
||||
readonly activeSearch = computed(() => {
|
||||
const search = this.currentStateSignal().search || null;
|
||||
console.log('🔍 activeSearch():', search);
|
||||
return search;
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// CONSTRUCTOR & LIFECYCLE
|
||||
// ========================================
|
||||
|
||||
constructor() {
|
||||
// Un seul flux: Router events -> query params -> état
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
|
||||
startWith(null as any),
|
||||
map(() => {
|
||||
const tree = this.router.parseUrl(this.router.url);
|
||||
const qpm = tree.queryParamMap;
|
||||
let params: Record<string, string> = {};
|
||||
for (const k of qpm.keys) {
|
||||
const v = qpm.get(k);
|
||||
if (v !== null) params[k] = v;
|
||||
}
|
||||
// Fallback: at first load with certain servers, Router may expose empty params
|
||||
if (Object.keys(params).length === 0 && typeof window !== 'undefined') {
|
||||
const sp = new URLSearchParams(window.location.search || '');
|
||||
sp.forEach((v, k) => { params[k] = v; });
|
||||
if (Object.keys(params).length > 0) {
|
||||
console.log('🌐 Fallback from window.location.search:', params);
|
||||
}
|
||||
}
|
||||
console.log('🌐 Router params parsed:', params);
|
||||
return params;
|
||||
}),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe(params => {
|
||||
console.log('🌐 UrlStateService - received params:', params);
|
||||
const newState = this.parseUrlParams(params);
|
||||
console.log('🌐 UrlStateService - new state:', newState);
|
||||
const previousState = this.currentStateSignal();
|
||||
|
||||
const changed = this.detectChanges(previousState, newState);
|
||||
console.log('🌐 UrlStateService - changed keys:', changed);
|
||||
if (changed.length > 0) {
|
||||
console.log('🌐 UrlStateService - updating state');
|
||||
this.previousStateSignal.set(previousState);
|
||||
this.currentStateSignal.set(newState);
|
||||
this.stateChangeSubject.next({ previous: previousState, current: newState, changed });
|
||||
} else if (!previousState || Object.keys(previousState).length === 0) {
|
||||
// Première initialisation si nécessaire
|
||||
console.log('🌐 UrlStateService - first initialization');
|
||||
this.previousStateSignal.set(newState);
|
||||
this.currentStateSignal.set(newState);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// INITIALIZATION
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Initialiser l'état depuis l'URL au démarrage
|
||||
*/
|
||||
private initializeFromUrl(): void { /* deprecated */ }
|
||||
|
||||
// ========================================
|
||||
// URL SYNCHRONIZATION
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Synchroniser l'état depuis l'URL
|
||||
*/
|
||||
private syncFromUrl(): void { /* deprecated - logic moved to constructor RxJS pipe */ }
|
||||
|
||||
/**
|
||||
* Parser les paramètres d'URL en état
|
||||
*/
|
||||
private parseUrlParams(params: any): UrlState {
|
||||
console.log('🔍 parseUrlParams input:', params);
|
||||
|
||||
// Objectif: autoriser la sélection d'une note ET d'un filtre de liste simultanément
|
||||
// - Conserver toujours 'note' si présent (ouvre la note)
|
||||
// - Appliquer AU PLUS UN filtre de liste avec priorité: tag > folder > quick
|
||||
// - Conserver toujours 'search' si présent
|
||||
|
||||
const state: UrlState = {};
|
||||
|
||||
// Note (peut coexister avec un filtre)
|
||||
if (params['note']) {
|
||||
state.note = decodeURIComponent(params['note']);
|
||||
console.log('✅ parseUrlParams - note set:', state.note);
|
||||
}
|
||||
|
||||
// Filtre unique: priorité tag > folder > quick
|
||||
if (params['tag']) {
|
||||
state.tag = decodeURIComponent(params['tag']);
|
||||
console.log('✅ parseUrlParams - tag set:', state.tag);
|
||||
} else if (params['folder']) {
|
||||
state.folder = decodeURIComponent(params['folder']);
|
||||
console.log('✅ parseUrlParams - folder set:', state.folder);
|
||||
} else if (params['quick']) {
|
||||
state.quick = decodeURIComponent(params['quick']);
|
||||
console.log('✅ parseUrlParams - quick set:', state.quick);
|
||||
}
|
||||
|
||||
// Recherche (toujours en plus)
|
||||
if (params['search']) {
|
||||
state.search = decodeURIComponent(params['search']);
|
||||
console.log('✅ parseUrlParams - search set:', state.search);
|
||||
}
|
||||
|
||||
console.log('🎯 parseUrlParams final state:', state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecter les changements entre deux états
|
||||
*/
|
||||
private detectChanges(previous: UrlState, current: UrlState): (keyof UrlState)[] {
|
||||
const changed: (keyof UrlState)[] = [];
|
||||
|
||||
const keys: (keyof UrlState)[] = ['note', 'tag', 'folder', 'quick', 'search'];
|
||||
|
||||
for (const key of keys) {
|
||||
if (previous[key] !== current[key]) {
|
||||
changed.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// STATE UPDATES
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Ouvrir une note via l'URL
|
||||
*/
|
||||
async openNote(notePath: string): Promise<void> {
|
||||
const note = this.vaultService.allNotes().find(n => n.filePath === notePath);
|
||||
|
||||
if (!note) {
|
||||
console.warn(`Note not found: ${notePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mettre à jour l'URL
|
||||
await this.updateUrl({ note: notePath });
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtrer par tag
|
||||
*/
|
||||
async filterByTag(tag: string): Promise<void> {
|
||||
// Vérifier que le tag existe
|
||||
const tags = this.vaultService.tags().map(t => t.name);
|
||||
if (!tags.includes(tag)) {
|
||||
console.warn(`Tag not found: ${tag}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mettre à jour l'URL
|
||||
await this.updateUrl({ tag, note: null, folder: null, quick: null, search: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtrer par dossier
|
||||
*/
|
||||
async filterByFolder(folder: string): Promise<void> {
|
||||
// Vérifier que le dossier existe
|
||||
const fileTree = this.vaultService.fileTree();
|
||||
const folderExists = this.folderExistsInTree(fileTree, folder);
|
||||
|
||||
if (!folderExists) {
|
||||
console.warn(`Folder not found: ${folder}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mettre à jour l'URL
|
||||
await this.updateUrl({ folder, note: null, tag: null, quick: null, search: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier si un dossier existe dans l'arborescence
|
||||
*/
|
||||
private folderExistsInTree(nodes: any[], targetPath: string): boolean {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'folder' && node.path === targetPath) {
|
||||
return true;
|
||||
}
|
||||
if (node.children && this.folderExistsInTree(node.children, targetPath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtrer par quick link
|
||||
*/
|
||||
async filterByQuickLink(quickLink: string): Promise<void> {
|
||||
const validQuickLinks = ['Favoris', 'Publié', 'Modèles', 'Tâches', 'Brouillons', 'Privé', 'Archive', 'Corbeille'];
|
||||
|
||||
if (!validQuickLinks.includes(quickLink)) {
|
||||
console.warn(`Invalid quick link: ${quickLink}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mettre à jour l'URL
|
||||
await this.updateUrl({ quick: quickLink, note: null, tag: null, folder: null, search: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour la recherche
|
||||
*/
|
||||
async updateSearch(searchTerm: string): Promise<void> {
|
||||
await this.updateUrl({ search: searchTerm || undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour l'URL avec le nouvel état
|
||||
*/
|
||||
private async updateUrl(partialState: Partial<UrlState>): Promise<void> {
|
||||
const currentState = this.currentStateSignal();
|
||||
const newState = { ...currentState, ...partialState };
|
||||
|
||||
// Nettoyer les propriétés undefined
|
||||
const cleanState = Object.fromEntries(
|
||||
Object.entries(newState).filter(([_, v]) => v !== undefined)
|
||||
);
|
||||
|
||||
// Naviguer avec les nouveaux queryParams
|
||||
await this.router.navigate([], {
|
||||
queryParams: cleanState,
|
||||
queryParamsHandling: 'merge',
|
||||
preserveFragment: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Réinitialiser l'état (retour à la vue par défaut)
|
||||
*/
|
||||
async resetState(): Promise<void> {
|
||||
await this.router.navigate([], {
|
||||
queryParams: {},
|
||||
queryParamsHandling: 'merge',
|
||||
preserveFragment: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Générer une URL partageble
|
||||
*/
|
||||
generateShareUrl(state: Partial<UrlState> = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const stateToShare = { ...this.currentStateSignal(), ...state };
|
||||
|
||||
if (stateToShare.note) params.set('note', stateToShare.note);
|
||||
if (stateToShare.tag) params.set('tag', stateToShare.tag);
|
||||
if (stateToShare.folder) params.set('folder', stateToShare.folder);
|
||||
if (stateToShare.quick) params.set('quick', stateToShare.quick);
|
||||
if (stateToShare.search) params.set('search', stateToShare.search);
|
||||
|
||||
const baseUrl = window.location.origin + window.location.pathname;
|
||||
return `${baseUrl}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copier l'URL actuelle dans le presse-papiers
|
||||
*/
|
||||
async copyCurrentUrlToClipboard(): Promise<void> {
|
||||
try {
|
||||
const url = this.generateShareUrl();
|
||||
await navigator.clipboard.writeText(url);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy URL:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// OBSERVABLES & EVENTS
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Observable des changements d'état
|
||||
*/
|
||||
get stateChange$() {
|
||||
return this.stateChangeSubject.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Écouter les changements d'une propriété spécifique
|
||||
*/
|
||||
onStatePropertyChange(property: keyof UrlState) {
|
||||
return this.stateChangeSubject.asObservable().pipe(
|
||||
filter(event => event.changed.includes(property))
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// HELPERS
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Vérifier si une note est actuellement ouverte
|
||||
*/
|
||||
isNoteOpen(notePath: string): boolean {
|
||||
return this.currentStateSignal().note === notePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier si un tag est actif
|
||||
*/
|
||||
isTagActive(tag: string): boolean {
|
||||
return this.currentStateSignal().tag === tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier si un dossier est actif
|
||||
*/
|
||||
isFolderActive(folder: string): boolean {
|
||||
return this.currentStateSignal().folder === folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier si un quick link est actif
|
||||
*/
|
||||
isQuickLinkActive(quickLink: string): boolean {
|
||||
return this.currentStateSignal().quick === quickLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir l'état actuel (snapshot)
|
||||
*/
|
||||
getState(): UrlState {
|
||||
return { ...this.currentStateSignal() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir l'état précédent (snapshot)
|
||||
*/
|
||||
getPreviousState(): UrlState {
|
||||
return { ...this.previousStateSignal() };
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,7 @@ type NoteAction =
|
||||
template: `
|
||||
<ng-container *ngIf="visible">
|
||||
<!-- Backdrop pour capter les clics extérieurs -->
|
||||
<div class="fixed inset-0" (click)="close()" aria-hidden="true" style="z-index: 9998;"></div>
|
||||
<div class="fixed inset-0" (click)="close()" aria-hidden="true" style="z-index: 9998; pointer-events: auto;"></div>
|
||||
|
||||
<!-- Menu -->
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user