- 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
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
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; pointer-events: auto;"></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();
|
|
}
|
|
}
|