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:
2025-10-24 13:45:02 -04:00
parent 83603e2d97
commit 0f7cc552ca
10 changed files with 1437 additions and 4 deletions
+86 -2
View File
@@ -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);
}
}
@@ -0,0 +1,369 @@
import {
Component,
ChangeDetectionStrategy,
ElementRef,
EventEmitter,
HostListener,
Input,
OnChanges,
OnDestroy,
Output,
Renderer2,
SimpleChanges,
ViewChild,
inject,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import type { Note } from '../../types';
import { ToastService } from '../../app/shared/toast/toast.service';
type NoteAction =
| 'duplicate'
| 'share'
| 'fullscreen'
| 'copy-link'
| 'favorite'
| 'info'
| 'readonly'
| 'delete';
@Component({
selector: 'app-note-context-menu',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [`
:host { position: fixed; inset: 0; pointer-events: none; z-index: 9999; }
.ctx {
pointer-events: auto;
min-width: 17.5rem;
max-width: 21.25rem;
border-radius: 1rem;
padding: 0.5rem 0;
box-shadow: 0 10px 30px rgba(0,0,0,.25);
backdrop-filter: blur(6px);
animation: fadeIn .12s ease-out;
transform-origin: top left;
user-select: none;
/* Theme-aware background and border */
background: var(--card, #ffffff);
border: 1px solid var(--border, #e5e7eb);
color: var(--fg, #111827);
z-index: 10000;
}
.item {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
height: 2.25rem;
text-align: left;
padding: 0 1rem;
border-radius: 0.5rem;
border: none;
background: transparent;
cursor: pointer;
font-size: 0.875rem;
transition: background-color 0.08s ease;
color: var(--text-main, #111827);
}
.item:hover {
background: color-mix(in oklab, var(--surface-1, #f8fafc) 90%, black 0%);
}
.item:active {
background: color-mix(in oklab, var(--surface-2, #eef2f7) 85%, black 0%);
}
.item.danger { color: var(--danger, #ef4444); }
.item.warning { color: var(--warning, #f59e0b); }
.item.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.sep {
border-top: 1px solid var(--border, #e5e7eb);
margin: 0.25rem 0;
}
.color-row {
display: flex;
align-items: center;
justify-content: space-around;
gap: 0.5rem;
padding: 0.5rem;
}
.color-dot {
width: 0.875rem;
height: 0.875rem;
border-radius: 9999px;
cursor: pointer;
transition: transform .08s ease, box-shadow .08s ease;
border: 2px solid transparent;
}
.color-dot:hover {
transform: scale(1.15);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--canvas, #ffffff) 70%, var(--fg, #111827) 15%);
}
.color-dot.active {
box-shadow: 0 0 0 2px var(--fg, #111827);
transform: scale(1.1);
}
.icon {
width: 1.125rem;
height: 1.125rem;
flex-shrink: 0;
}
@keyframes fadeIn { from { opacity:0; transform: scale(.95);} to { opacity:1; transform: scale(1);} }
`],
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>
<!-- Menu -->
<div
#menu
class="ctx"
[ngStyle]="{ left: left + 'px', top: top + 'px', position:'fixed' }"
role="menu"
(contextmenu)="$event.preventDefault()"
>
<!-- Duplicate -->
<button class="item" (click)="emitAction('duplicate')" [class.disabled]="!canDuplicate">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
Dupliquer
</button>
<!-- Share page -->
<button class="item" (click)="emitAction('share')" [class.disabled]="!canShare">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="18" cy="5" r="3"></circle>
<circle cx="6" cy="12" r="3"></circle>
<circle cx="18" cy="19" r="3"></circle>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line>
</svg>
Partager la page
</button>
<!-- Open in full screen -->
<button class="item" (click)="emitAction('fullscreen')">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path>
</svg>
Ouvrir en plein écran
</button>
<!-- Copy internal link -->
<button class="item" (click)="emitAction('copy-link')">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
Copier le lien interne
</button>
<div class="sep"></div>
<!-- Add to Favorites -->
<button class="item" (click)="emitAction('favorite')">
<svg class="icon" viewBox="0 0 24 24" [attr.fill]="note?.frontmatter?.favoris ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
{{ note?.frontmatter?.favoris ? 'Retirer des favoris' : 'Ajouter aux favoris' }}
</button>
<!-- Page Information -->
<button class="item" (click)="emitAction('info')">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
Informations de la page
</button>
<!-- Read Only toggle -->
<button class="item" (click)="emitAction('readonly')" [class.disabled]="!canToggleReadOnly">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
{{ note?.frontmatter?.readOnly ? 'Mode lecture' : 'Lecture seule' }}
</button>
<div class="sep"></div>
<!-- Delete -->
<button class="item danger" (click)="emitAction('delete')" [class.disabled]="!canDelete">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
Supprimer
</button>
<div class="sep"></div>
<!-- Color palette -->
<div class="color-row" role="group" aria-label="Couleur de la note">
<div *ngFor="let c of colors"
class="color-dot"
[class.active]="note?.frontmatter?.color === c"
[style.background]="c"
(click)="emitColor(c)"
[attr.aria-label]="'Couleur ' + c"
role="button"
title="Définir la couleur de la note"></div>
<!-- Clear color option -->
<div class="color-dot"
[class.active]="!note?.frontmatter?.color"
style="background: conic-gradient(from 45deg, #ef4444, #f59e0b, #22c55e, #3b82f6, #a855f7, #ef4444);"
(click)="emitColor('')"
attr.aria-label="Aucune couleur"
role="button"
title="Retirer la couleur">
<svg class="icon" style="width: 0.75rem; height: 0.75rem;" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</div>
</div>
</div>
</ng-container>
`,
})
export class NoteContextMenuComponent implements OnChanges, OnDestroy {
/** Position demandée (pixels viewport) */
@Input() x = 0;
@Input() y = 0;
/** Contrôle d'affichage */
@Input() visible = false;
/** Note concernée */
@Input() note: Note | null = null;
/** Actions/retours */
@Output() action = new EventEmitter<NoteAction>();
@Output() color = new EventEmitter<string>();
@Output() closed = new EventEmitter<void>();
/** Palette 8 couleurs + option pour effacer */
colors = ['#00AEEF', '#3B82F6', '#22C55E', '#F59E0B', '#EF4444', '#A855F7', '#8B5CF6', '#64748B'];
/** Position corrigée (anti overflow) */
left = 0;
top = 0;
@ViewChild('menu') menuRef?: ElementRef<HTMLElement>;
private removeResize?: () => void;
private removeScroll?: () => void;
private toastService = inject(ToastService);
constructor(private r2: Renderer2, private host: ElementRef<HTMLElement>) {
// listeners globaux qui ferment le menu
this.removeResize = this.r2.listen('window', 'resize', () => this.reposition());
this.removeScroll = this.r2.listen('window', 'scroll', () => this.reposition());
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['visible'] && this.visible) {
// Immediately set to click position to avoid flashing at 0,0
this.left = this.x;
this.top = this.y;
// Then reposition for anti-overflow
queueMicrotask(() => this.reposition());
}
if ((changes['x'] || changes['y']) && this.visible) {
queueMicrotask(() => this.reposition());
}
}
ngOnDestroy(): void {
this.removeResize?.();
this.removeScroll?.();
}
/** Ferme le menu */
close() {
if (!this.visible) return;
this.visible = false;
this.closed.emit();
}
emitAction(a: NoteAction) {
// Check permissions before emitting
if (a === 'duplicate' && !this.canDuplicate) {
this.toastService.warning('Action non disponible en lecture seule');
return;
}
if (a === 'share' && !this.canShare) {
this.toastService.warning('Partage non disponible pour cette note');
return;
}
if (a === 'readonly' && !this.canToggleReadOnly) {
this.toastService.warning('Modification des permissions non disponible');
return;
}
if (a === 'delete' && !this.canDelete) {
this.toastService.warning('Suppression non disponible pour cette note');
return;
}
this.action.emit(a);
this.close();
}
emitColor(c: string) {
this.color.emit(c);
this.close();
}
/** Permissions calculées */
get canDuplicate(): boolean {
return !this.note?.frontmatter?.readOnly;
}
get canShare(): boolean {
// Vérifier si le partage public est activé dans la config
// et si la note n'est pas privée
return this.note?.frontmatter?.publish !== false &&
this.note?.frontmatter?.private !== true;
}
get canToggleReadOnly(): boolean {
// Autorisé si on n'est pas en lecture seule globale
return true; // Pour l'instant, on autorise toujours
}
get canDelete(): boolean {
return true; // Pour l'instant, on autorise toujours
}
/** Corrige la position si le menu sortirait du viewport */
private reposition() {
const el = this.menuRef?.nativeElement;
if (!el) { this.left = this.x; this.top = this.y; return; }
const menuRect = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = this.x;
let top = this.y;
if (left + menuRect.width > vw - 8) left = Math.max(8, vw - menuRect.width - 8);
if (top + menuRect.height > vh - 8) top = Math.max(8, vh - menuRect.height - 8);
this.left = left;
this.top = top;
}
/** Fermer avec ESC */
@HostListener('window:keydown', ['$event'])
onKey(e: KeyboardEvent) {
if (e.key === 'Escape') this.close();
}
}
+2
View File
@@ -15,6 +15,8 @@ export interface NoteFrontmatter {
archive?: boolean;
draft?: boolean;
private?: boolean;
readOnly?: boolean;
color?: string;
[key: string]: unknown;
}