import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, OnDestroy, OnInit, ViewChild, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink, RouterLinkActive } from '@angular/router'; import { ViewportScroller } from '@angular/common'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { Subscription } from 'rxjs'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; import { SpecDocument, SpecService } from '../../core/services/spec.service'; import { AuthService } from '../../core/services/auth.service'; import { Annotation, AnnotationService, StatutAnnotation, TypeAnnotation } from '../../core/services/annotation.service'; /** * `SpecsComponent` — consultation des specs (Markdown rendu en direct) + annotations. * * Chaque titre de section reçoit un identifiant (slug) et un bouton « 💬 » permettant * d'annoter la section. Les annotations sont listées dans un panneau latéral droit ; * leur création/édition/suppression passe par l'API (droits : auteur ou admin). */ @Component({ selector: 'app-specs', standalone: true, imports: [FormsModule, RouterLink, RouterLinkActive], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './specs.component.html', styleUrl: './specs.component.scss' }) export class SpecsComponent implements OnInit, OnDestroy { private readonly specService = inject(SpecService); private readonly annotationService = inject(AnnotationService); private readonly auth = inject(AuthService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly scroller = inject(ViewportScroller); private readonly sanitizer = inject(DomSanitizer); private readonly cdr = inject(ChangeDetectorRef); @ViewChild('markdownHost') private markdownHost?: ElementRef; readonly documents = signal([]); readonly cleCourante = signal(null); readonly titreCourant = signal(''); readonly contenuHtml = signal(null); readonly chargement = signal(false); readonly erreur = signal(null); // --- Annotations --- readonly annotations = signal([]); readonly panneauOuvert = signal(false); readonly login = this.auth.login; readonly estAdmin = this.auth.estAdmin; /** L'utilisateur peut-il annoter (admin/contributeur) ? Sinon lecture seule. */ readonly peutContribuer = this.auth.peutContribuer; /** Nombre d'annotations ouvertes sur le document courant (badge). */ readonly nbOuvertes = computed( () => this.annotations().filter((a) => a.statut === 'ouverte').length ); /** Nombre d'annotations traitées sur le document courant. */ readonly nbTraitees = computed( () => this.annotations().filter((a) => a.statut === 'traitee').length ); /** Filtre d'affichage : masquer les annotations traitées. */ readonly masquerTraitees = signal(false); /** Repli du formulaire de saisie/édition (pour agrandir la liste). */ readonly formReplie = signal(false); /** Annotations réellement affichées dans la liste, selon le filtre. */ readonly annotationsAffichees = computed(() => this.masquerTraitees() ? this.annotations().filter((a) => a.statut !== 'traitee') : this.annotations() ); // Formulaire de création/édition readonly editionId = signal(null); // null = création readonly fTexte = signal(''); readonly fType = signal('remarque'); readonly fAncre = signal(''); readonly fTitreSection = signal(''); readonly envoiEnCours = signal(false); readonly erreurForm = signal(null); private listeSub?: Subscription; private contenuSub?: Subscription; private paramSub?: Subscription; private readonly souscriptions: Subscription[] = []; private readonly markedInstance = this.creerMarked(); /** * Gestionnaire de clic délégué sur le contenu rendu : * - boutons « annoter » (.btn-annoter) -> ouvre le formulaire d'annotation ; * - liens internes vers un autre document (.lien-interne) -> navigation SPA ; * - liens d'ancre pure (#section) -> défilement vers la section. */ private readonly onClicContenu = (ev: Event) => { const el = ev.target as HTMLElement; // 1) Bouton d'annotation const btn = el?.closest('.btn-annoter') as HTMLElement | null; if (btn) { ev.preventDefault(); this.ouvrirCreation(btn.getAttribute('data-ancre') || '', btn.getAttribute('data-titre') || ''); return; } // 2) Lien (document interne ou ancre) const lien = el?.closest('a') as HTMLAnchorElement | null; if (!lien) { return; } // Lien interne vers un autre document de specs (réécrit par le renderer). const cleCible = lien.getAttribute('data-cle'); if (cleCible) { ev.preventDefault(); const fragment = lien.getAttribute('data-fragment') || undefined; void this.router.navigate(['/specs', cleCible], { fragment }); if (fragment) { setTimeout(() => this.scroller.scrollToAnchor(fragment), 50); } else { this.scroller.scrollToPosition([0, 0]); } return; } // Lien d'ancre pure (#id) dans le document courant. const href = lien.getAttribute('href') || ''; if (href.startsWith('#')) { ev.preventDefault(); this.scroller.scrollToAnchor(href.slice(1)); } }; ngOnInit(): void { this.listeSub = this.specService.lister().subscribe({ next: (docs) => { this.documents.set(docs); this.cdr.markForCheck(); }, error: () => { this.erreur.set('Impossible de charger la liste des documents.'); this.cdr.markForCheck(); } }); this.paramSub = this.route.paramMap.subscribe((params) => { const cle = params.get('cle'); if (cle) { this.chargerDocument(cle); this.chargerAnnotations(cle); } }); } ngOnDestroy(): void { this.listeSub?.unsubscribe(); this.contenuSub?.unsubscribe(); this.paramSub?.unsubscribe(); this.souscriptions.forEach((s) => s.unsubscribe()); this.markdownHost?.nativeElement.removeEventListener('click', this.onClicContenu); } rafraichir(): void { const cle = this.cleCourante(); if (cle) { this.chargerDocument(cle); this.chargerAnnotations(cle); } } // ------------------------------------------------------------------ Document private chargerDocument(cle: string): void { this.contenuSub?.unsubscribe(); this.cleCourante.set(cle); this.chargement.set(true); this.erreur.set(null); this.contenuHtml.set(null); this.cdr.markForCheck(); this.contenuSub = this.specService.obtenir(cle).subscribe({ next: (doc) => { this.titreCourant.set(doc.titre); const html = this.markedInstance.parse(doc.markdown, { async: false }) as string; const propre = DOMPurify.sanitize(html, { ADD_ATTR: [ 'class', 'id', 'data-ancre', 'data-titre', 'data-cle', 'data-fragment', 'title', 'type', 'target', 'rel' ] }); this.contenuHtml.set(this.sanitizer.bypassSecurityTrustHtml(propre)); this.chargement.set(false); this.cdr.markForCheck(); setTimeout(() => { void this.rendreMermaid(); // (Ré)attache le gestionnaire de clic délégué sur le conteneur injecté. const hote = this.markdownHost?.nativeElement; if (hote) { hote.removeEventListener('click', this.onClicContenu); hote.addEventListener('click', this.onClicContenu); } }, 0); }, error: () => { this.chargement.set(false); this.erreur.set("Ce document est introuvable ou n'a pas pu être chargé."); this.cdr.markForCheck(); } }); } // --------------------------------------------------------------- Annotations private chargerAnnotations(cle: string): void { const sub = this.annotationService.lister(cle).subscribe({ next: (liste) => { this.annotations.set(liste); this.cdr.markForCheck(); }, error: () => { // Silencieux : l'absence d'annotations ne doit pas bloquer la lecture. this.annotations.set([]); this.cdr.markForCheck(); } }); this.souscriptions.push(sub); } basculerPanneau(): void { this.panneauOuvert.update((v) => !v); } /** Annotations à afficher pour une ancre donnée (regroupées sous chaque section). */ annotationsPourAncre(ancre: string): Annotation[] { return this.annotations().filter((a) => a.ancre === ancre); } /** Ouvre le formulaire en création pour une section. */ ouvrirCreation(ancre: string, titreSection: string): void { this.editionId.set(null); this.fTexte.set(''); this.fType.set('remarque'); this.fAncre.set(ancre); this.fTitreSection.set(titreSection); this.erreurForm.set(null); this.panneauOuvert.set(true); this.formReplie.set(false); // déplie le formulaire pour saisir this.cdr.markForCheck(); } /** Ouvre le formulaire en édition d'une annotation existante. */ ouvrirEdition(a: Annotation): void { this.editionId.set(a.id); this.fTexte.set(a.texte); this.fType.set(a.type); this.fAncre.set(a.ancre); this.fTitreSection.set(a.titreSection); this.erreurForm.set(null); this.panneauOuvert.set(true); this.formReplie.set(false); // déplie le formulaire pour éditer this.cdr.markForCheck(); } annulerForm(): void { this.editionId.set(null); this.fTexte.set(''); this.erreurForm.set(null); } /** Droit de modifier/supprimer : admin, ou auteur ayant un accès contributeur. */ peutModifier(a: Annotation): boolean { if (this.estAdmin()) { return true; } return this.peutContribuer() && a.auteur === this.login(); } soumettreForm(): void { const cle = this.cleCourante(); if (!cle) { return; } const texte = this.fTexte().trim(); if (!texte) { this.erreurForm.set('Le texte est requis.'); return; } this.envoiEnCours.set(true); this.erreurForm.set(null); const id = this.editionId(); if (id) { // Édition const sub = this.annotationService .mettreAJour(cle, id, { texte, type: this.fType() }) .subscribe({ next: (maj) => { this.annotations.update((l) => l.map((a) => (a.id === maj.id ? maj : a))); this.envoiEnCours.set(false); this.annulerForm(); this.cdr.markForCheck(); }, error: () => { this.envoiEnCours.set(false); this.erreurForm.set("Échec de l'enregistrement."); this.cdr.markForCheck(); } }); this.souscriptions.push(sub); } else { // Création const sub = this.annotationService .creer(cle, { texte, type: this.fType(), ancre: this.fAncre(), titreSection: this.fTitreSection() }) .subscribe({ next: (creee) => { this.annotations.update((l) => [...l, creee]); this.envoiEnCours.set(false); this.annulerForm(); this.cdr.markForCheck(); }, error: () => { this.envoiEnCours.set(false); this.erreurForm.set("Échec de l'enregistrement."); this.cdr.markForCheck(); } }); this.souscriptions.push(sub); } } basculerStatut(a: Annotation): void { const cle = this.cleCourante(); if (!cle) { return; } const nouveau: StatutAnnotation = a.statut === 'ouverte' ? 'traitee' : 'ouverte'; const sub = this.annotationService.mettreAJour(cle, a.id, { statut: nouveau }).subscribe({ next: (maj) => { this.annotations.update((l) => l.map((x) => (x.id === maj.id ? maj : x))); this.cdr.markForCheck(); }, error: () => this.cdr.markForCheck() }); this.souscriptions.push(sub); } supprimer(a: Annotation): void { const cle = this.cleCourante(); if (!cle) { return; } const sub = this.annotationService.supprimer(cle, a.id).subscribe({ next: () => { this.annotations.update((l) => l.filter((x) => x.id !== a.id)); this.cdr.markForCheck(); }, error: () => this.cdr.markForCheck() }); this.souscriptions.push(sub); } // L'export des annotations est désormais accessible uniquement depuis l'en-tête du // site (composant racine), pour l'administrateur. Il a donc été retiré de ce panneau. // ------------------------------------------------------------------- Mermaid private async rendreMermaid(): Promise { const hote = this.markdownHost?.nativeElement; if (!hote) { return; } const blocs = hote.querySelectorAll('pre.mermaid'); if (blocs.length === 0) { return; } try { const mermaid = (await import('mermaid')).default; mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'loose' }); await mermaid.run({ nodes: Array.from(blocs) }); } catch { // Repli : le bloc de code brut reste visible si le rendu échoue. } } // -------------------------------------------------------------------- marked private creerMarked(): typeof marked { const slugs = this.compteurSlugs(); // Lu à chaque rendu : masque le bouton « annoter » pour les comptes en lecture seule. const peutContribuer = () => this.peutContribuer(); marked.use({ renderer: { code(this: unknown, { text, lang }: { text: string; lang?: string }): string { if ((lang ?? '').trim().toLowerCase() === 'mermaid') { return `
${SpecsComponent.echapper(text)}
`; } const langue = (lang ?? '').trim(); const classe = langue ? ` class="language-${SpecsComponent.echapper(langue)}"` : ''; return `
${SpecsComponent.echapper(text)}
`; }, link({ href, title, text }: { href: string; title?: string | null; text: string }): string { // `text` est le libellé déjà rendu (HTML inline) du lien. const texte = text; const t = title ? ` title="${SpecsComponent.echapper(title)}"` : ''; // Lien interne vers un autre document de specs : "06-prestations.md" ou // "06-prestations.md#4-1" ou "./06-prestations.md". On réécrit en route SPA. const interne = /^(?:\.\/)?([a-z0-9][a-z0-9-]*)\.md(?:#([^)]*))?$/i.exec(href || ''); if (interne) { const cle = interne[1]; const fragment = interne[2] || ''; const dataFragment = fragment ? ` data-fragment="${SpecsComponent.echapper(fragment)}"` : ''; return `${texte}`; } // Lien externe : ouvre dans un nouvel onglet, sécurisé. const estExterne = /^https?:\/\//i.test(href || ''); const rel = estExterne ? ' target="_blank" rel="noopener noreferrer"' : ''; return `${texte}`; }, heading({ text, depth, tokens }: { text: string; depth: number; tokens?: unknown }): string { // `text` peut contenir du HTML inline déjà rendu ; on dérive un libellé propre. const brut = SpecsComponent.texteBrut(text); const ancre = slugs(brut); // Bouton d'annotation : pas sur le H1 (titre du document), et seulement si // l'utilisateur peut contribuer (masqué en lecture seule). const bouton = depth === 1 || !peutContribuer() ? '' : ` `; return `${text}${bouton}`; } } }); return marked; } /** Fabrique un générateur de slugs uniques (gère les doublons par suffixe -n). */ private compteurSlugs(): (texte: string) => string { const vus = new Map(); return (texte: string) => { const base = texte .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') // accents .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') || 'section'; const n = vus.get(base) ?? 0; vus.set(base, n + 1); return n === 0 ? base : `${base}-${n}`; }; } private static texteBrut(html: string): string { return html .replace(/<[^>]+>/g, '') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .trim(); } private static echapper(valeur: string): string { return valeur .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } }