feat: add note context menu with color and metadata controls
- Added PATCH endpoint for updating note frontmatter with YAML parsing and merging - Added DELETE endpoint to move notes to .trash directory with timestamped filenames - Implemented note context menu with actions like duplicate, share, favorite, and delete - Added color picker to context menu with gradient background visualization - Extended NoteFrontmatter type with readOnly and color properties - Added YAML frontmatter parser with support
This commit is contained in:
@@ -6,11 +6,13 @@ import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrol
|
||||
import { TagFilterStore } from '../../core/stores/tag-filter.store';
|
||||
import { NotesListStateService, SortBy, ViewMode } from '../../services/notes-list-state.service';
|
||||
import { NoteCreationService } from '../../services/note-creation.service';
|
||||
import { NoteContextMenuComponent } from '../../../components/note-context-menu/note-context-menu.component';
|
||||
import { NoteContextMenuService } from '../../services/note-context-menu.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notes-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ScrollableOverlayDirective],
|
||||
imports: [CommonModule, ScrollableOverlayDirective, NoteContextMenuComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="h-full flex flex-col">
|
||||
@@ -132,7 +134,10 @@ import { NoteCreationService } from '../../services/note-creation.service';
|
||||
class="note-row cursor-pointer"
|
||||
[class.active]="selectedId() === n.id"
|
||||
[ngClass]="getListItemClasses()"
|
||||
(click)="openNote.emit(n.id)">
|
||||
[ngStyle]="getNoteGradientStyle(n)"
|
||||
[attr.data-note-path]="n.filePath"
|
||||
(click)="openNote.emit(n.id)"
|
||||
(contextmenu)="openContextMenu($event, n)">
|
||||
<!-- Compact View -->
|
||||
<div *ngIf="state.viewMode() === 'compact'" class="note-inner">
|
||||
<div class="title text-xs truncate">{{ n.title }}</div>
|
||||
@@ -157,6 +162,17 @@ import { NoteCreationService } from '../../services/note-creation.service';
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Note Context Menu -->
|
||||
<app-note-context-menu
|
||||
[x]="contextMenuService.x()"
|
||||
[y]="contextMenuService.y()"
|
||||
[visible]="contextMenuService.visible()"
|
||||
[note]="contextMenuService.targetNote()"
|
||||
(action)="onContextMenuAction($event)"
|
||||
(color)="onContextMenuColor($event)"
|
||||
(closed)="contextMenuService.close()">
|
||||
</app-note-context-menu>
|
||||
`,
|
||||
styles: [`
|
||||
:host {
|
||||
@@ -349,6 +365,7 @@ export class NotesListComponent {
|
||||
private store = inject(TagFilterStore);
|
||||
readonly state = inject(NotesListStateService);
|
||||
private noteCreationService = inject(NoteCreationService);
|
||||
readonly contextMenuService = inject(NoteContextMenuService);
|
||||
|
||||
private q = signal('');
|
||||
activeTag = signal<string | null>(null);
|
||||
@@ -543,4 +560,71 @@ export class NotesListComponent {
|
||||
this.state.setRequestStats(false, 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Context menu methods
|
||||
openContextMenu(event: MouseEvent, note: Note) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.contextMenuService.openForNote(note, { x: event.clientX, y: event.clientY });
|
||||
}
|
||||
|
||||
async onContextMenuAction(action: string) {
|
||||
const note = this.contextMenuService.targetNote();
|
||||
if (!note) return;
|
||||
|
||||
switch (action) {
|
||||
case 'duplicate':
|
||||
await this.contextMenuService.duplicateNote(note);
|
||||
break;
|
||||
case 'share':
|
||||
await this.contextMenuService.shareNote(note);
|
||||
break;
|
||||
case 'fullscreen':
|
||||
this.contextMenuService.openFullScreen(note);
|
||||
break;
|
||||
case 'copy-link':
|
||||
await this.contextMenuService.copyInternalLink(note);
|
||||
break;
|
||||
case 'favorite':
|
||||
await this.contextMenuService.toggleFavorite(note);
|
||||
break;
|
||||
case 'info':
|
||||
this.contextMenuService.showPageInfo(note);
|
||||
break;
|
||||
case 'readonly':
|
||||
await this.contextMenuService.toggleReadOnly(note);
|
||||
break;
|
||||
case 'delete':
|
||||
await this.contextMenuService.deleteNote(note);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async onContextMenuColor(color: string) {
|
||||
const note = this.contextMenuService.targetNote();
|
||||
if (!note) return;
|
||||
|
||||
await this.contextMenuService.changeNoteColor(note, color);
|
||||
}
|
||||
|
||||
// Get gradient style for note based on color
|
||||
getNoteGradientStyle(note: Note): Record<string, string> | null {
|
||||
const color = note.frontmatter?.color;
|
||||
if (!color) return null;
|
||||
|
||||
// Convert hex to rgba with transparency for gradient
|
||||
const hexMatch = /^#([0-9a-fA-F]{6})$/.exec(color);
|
||||
let gradientColor = color;
|
||||
if (hexMatch) {
|
||||
const hex = hexMatch[1];
|
||||
const r = parseInt(hex.slice(0,2), 16);
|
||||
const g = parseInt(hex.slice(2,4), 16);
|
||||
const b = parseInt(hex.slice(4,6), 16);
|
||||
gradientColor = `rgba(${r}, ${g}, ${b}, 0.14)`;
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundImage: `linear-gradient(to left, ${gradientColor} 0%, transparent 65%)`
|
||||
} as Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { Injectable, signal, inject } from '@angular/core';
|
||||
import type { Note } from '../../types';
|
||||
import { ToastService } from '../shared/toast/toast.service';
|
||||
import { VaultService } from './vault.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NoteContextMenuService {
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly vaultService = inject(VaultService);
|
||||
|
||||
// État du menu
|
||||
readonly visible = signal(false);
|
||||
readonly x = signal(0);
|
||||
readonly y = signal(0);
|
||||
readonly targetNote = signal<Note | null>(null);
|
||||
|
||||
// Ouvrir le menu pour une note
|
||||
openForNote(note: Note, anchorPoint: { x: number; y: number }) {
|
||||
this.targetNote.set(note);
|
||||
this.x.set(anchorPoint.x);
|
||||
this.y.set(anchorPoint.y);
|
||||
this.visible.set(true);
|
||||
}
|
||||
|
||||
// Fermer le menu
|
||||
close() {
|
||||
this.visible.set(false);
|
||||
this.targetNote.set(null);
|
||||
}
|
||||
|
||||
// Actions du menu
|
||||
async duplicateNote(note: Note): Promise<void> {
|
||||
try {
|
||||
const folderPath = note.filePath.split('/').slice(0, -1).join('/');
|
||||
const baseName = note.title;
|
||||
|
||||
// Générer un nom unique
|
||||
let newName = `${baseName} copy`;
|
||||
let counter = 2;
|
||||
|
||||
// Vérifier si le nom existe déjà
|
||||
const existingNotes = this.vaultService.allNotes();
|
||||
while (existingNotes.some(n => n.title === newName)) {
|
||||
newName = `${baseName} (${counter})`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Créer la note dupliquée
|
||||
const response = await fetch('/api/vault/notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fileName: newName,
|
||||
folderPath: folderPath,
|
||||
frontmatter: {
|
||||
...note.frontmatter,
|
||||
readOnly: false, // La copie n'est jamais en lecture seule
|
||||
color: note.frontmatter?.color
|
||||
},
|
||||
content: note.content
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to duplicate note');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
this.toast.success(`Page dupliquée: ${newName}`);
|
||||
|
||||
// Rafraîchir la liste et sélectionner la nouvelle note
|
||||
this.vaultService.refresh();
|
||||
|
||||
// Émettre un événement pour la sélection
|
||||
this.emitEvent('noteDuplicated', { path: result.filePath, noteId: result.id });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Duplicate note error:', error);
|
||||
this.toast.error('Échec de la duplication de la page');
|
||||
}
|
||||
}
|
||||
|
||||
async shareNote(note: Note): Promise<void> {
|
||||
try {
|
||||
// Vérifier si le partage est autorisé
|
||||
if (note.frontmatter?.private === true) {
|
||||
this.toast.warning('Cette page est privée et ne peut être partagée');
|
||||
return;
|
||||
}
|
||||
|
||||
// Générer l'URL de partage
|
||||
const encodedPath = encodeURIComponent(note.filePath);
|
||||
const shareUrl = `${window.location.origin}/#/note/${encodedPath}`;
|
||||
|
||||
// Copier dans le presse-papiers
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
this.toast.success('Lien de partage copié');
|
||||
|
||||
this.emitEvent('noteShared', { path: note.filePath, shareUrl });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Share note error:', error);
|
||||
this.toast.error('Échec du partage de la page');
|
||||
}
|
||||
}
|
||||
|
||||
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)');
|
||||
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}]]`;
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(link);
|
||||
this.toast.success('Lien interne copié');
|
||||
|
||||
this.emitEvent('noteCopiedLink', { path: note.filePath, link });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Copy internal link error:', error);
|
||||
this.toast.error('Échec de la copie du lien interne');
|
||||
}
|
||||
}
|
||||
|
||||
async toggleFavorite(note: Note): Promise<void> {
|
||||
try {
|
||||
const isFavorite = !note.frontmatter?.favoris;
|
||||
|
||||
// Mettre à jour le frontmatter
|
||||
const response = await fetch(`/api/vault/notes/${note.id}/frontmatter`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
favoris: isFavorite
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update favorite status');
|
||||
}
|
||||
|
||||
this.toast.success(isFavorite ? 'Ajouté aux favoris' : 'Retiré des favoris');
|
||||
|
||||
// Mettre à jour le frontmatter local
|
||||
if (note.frontmatter) {
|
||||
note.frontmatter.favoris = isFavorite;
|
||||
}
|
||||
|
||||
this.emitEvent('noteFavoriteToggled', { path: note.filePath, isFavorite });
|
||||
|
||||
// Rafraîchir les compteurs
|
||||
this.vaultService.refresh();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Toggle favorite error:', error);
|
||||
this.toast.error('Échec de la mise à jour des favoris');
|
||||
}
|
||||
}
|
||||
|
||||
showPageInfo(note: Note): void {
|
||||
// Calculer les statistiques
|
||||
const wordCount = note.content.split(/\s+/).length;
|
||||
const headingCount = (note.content.match(/^#+\s/gm) || []).length;
|
||||
const backlinksCount = note.backlinks?.length || 0;
|
||||
|
||||
const info = `
|
||||
📄 Informations de la page
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📁 Chemin: ${note.filePath}
|
||||
📝 Titre: ${note.title}
|
||||
📊 Mots: ${wordCount}
|
||||
🏷️ Titres: ${headingCount}
|
||||
🔗 Liens entrants: ${backlinksCount}
|
||||
📅 Créée: ${note.createdAt || 'Inconnue'}
|
||||
🔄 Modifiée: ${note.updatedAt || new Date(note.mtime).toLocaleDateString('fr-FR')}
|
||||
🏷️ Tags: ${note.tags?.join(', ') || 'Aucun'}
|
||||
${note.frontmatter?.color ? `🎨 Couleur: ${note.frontmatter.color}` : ''}
|
||||
${note.frontmatter?.readOnly ? '🔒 Lecture seule' : '✏️ Modifiable'}
|
||||
${note.frontmatter?.favoris ? '⭐ Favori' : ''}
|
||||
`.trim();
|
||||
|
||||
console.log(info);
|
||||
this.toast.info('Informations affichées dans la console');
|
||||
|
||||
this.emitEvent('noteInfoRequested', { path: note.filePath, info });
|
||||
}
|
||||
|
||||
async toggleReadOnly(note: Note): Promise<void> {
|
||||
try {
|
||||
const isReadOnly = !note.frontmatter?.readOnly;
|
||||
|
||||
// Mettre à jour le frontmatter
|
||||
const response = await fetch(`/api/vault/notes/${note.id}/frontmatter`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
readOnly: isReadOnly
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update read-only status');
|
||||
}
|
||||
|
||||
this.toast.success(isReadOnly ? 'Lecture seule activée' : 'Lecture seule désactivée');
|
||||
|
||||
// Mettre à jour le frontmatter local
|
||||
if (note.frontmatter) {
|
||||
note.frontmatter.readOnly = isReadOnly;
|
||||
}
|
||||
|
||||
this.emitEvent('noteReadOnlyToggled', { path: note.filePath, isReadOnly });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Toggle read-only error:', error);
|
||||
this.toast.error('Échec de la mise à jour du mode lecture seule');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteNote(note: Note): Promise<void> {
|
||||
try {
|
||||
// Demander confirmation
|
||||
const confirmMessage = note.frontmatter?.readOnly
|
||||
? `Supprimer cette page ?\n\nLa page "${note.title}" sera déplacée vers .trash et pourra être restaurée.\n\n⚠️ Cette page est en lecture seule. Cochez "Je comprends" pour continuer.`
|
||||
: `Supprimer cette page ?\n\nLa page "${note.title}" sera déplacée vers .trash et pourra être restaurée.`;
|
||||
|
||||
const confirmed = confirm(confirmMessage);
|
||||
if (!confirmed) return;
|
||||
|
||||
// Déplacer vers la corbeille
|
||||
const response = await fetch(`/api/vault/notes/${note.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete note');
|
||||
}
|
||||
|
||||
this.toast.success(`Page supprimée: ${note.title}`);
|
||||
|
||||
this.emitEvent('noteDeleted', { path: note.filePath });
|
||||
|
||||
// Rafraîchir la liste
|
||||
this.vaultService.refresh();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Delete note error:', error);
|
||||
this.toast.error('Échec de la suppression de la page');
|
||||
}
|
||||
}
|
||||
|
||||
async changeNoteColor(note: Note, color: string): Promise<void> {
|
||||
try {
|
||||
// Mettre à jour le frontmatter
|
||||
const response = await fetch(`/api/vault/notes/${note.id}/frontmatter`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
color: color || null // null pour supprimer la couleur
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update note color');
|
||||
}
|
||||
|
||||
this.toast.success(color ? 'Couleur de la note mise à jour' : 'Couleur de la note retirée');
|
||||
|
||||
// Mettre à jour le frontmatter local
|
||||
if (note.frontmatter) {
|
||||
if (color) {
|
||||
note.frontmatter.color = color;
|
||||
} else {
|
||||
delete note.frontmatter.color;
|
||||
}
|
||||
}
|
||||
|
||||
this.emitEvent('noteColorChanged', { path: note.filePath, color });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Change note color error:', error);
|
||||
this.toast.error('Échec de la mise à jour de la couleur');
|
||||
}
|
||||
}
|
||||
|
||||
// Émettre des événements personnalisés
|
||||
private emitEvent(eventType: string, data: any) {
|
||||
const event = new CustomEvent(eventType, { detail: data });
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user