This commit is contained in:
2026-08-17 19:08:20 +02:00
parent 312890f6e7
commit 8a8f748784
239 changed files with 369374 additions and 0 deletions

9
frontend/.dockerignore Normal file
View File

@ -0,0 +1,9 @@
node_modules
dist
.angular
.git
.gitignore
*.log
Dockerfile
.dockerignore
README.md

12
frontend/.editorconfig Normal file
View File

@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
max_line_length = off
trim_trailing_whitespace = false

17
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,17 @@
# Dépendances
/node_modules
# Build Angular
/dist
/out-tsc
/.angular/cache
# IDE
/.idea
/.vscode/*
!/.vscode/extensions.json
# Logs et divers
*.log
.DS_Store
Thumbs.db

27
frontend/Dockerfile Normal file
View File

@ -0,0 +1,27 @@
# --- Étape 1 : build de l'application Angular ---
FROM node:22-alpine AS build
WORKDIR /app
# Installation des dépendances (couche mise en cache tant que les manifestes
# ne changent pas). On utilise `npm ci` si un lockfile est présent, sinon
# `npm install` (utile tant que le lockfile n'a pas encore été généré/commité).
COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
# Copie du code source et build de production.
COPY . .
RUN npm run build
# --- Étape 2 : service par nginx ---
FROM nginx:1.27-alpine
# Configuration nginx (SPA + proxy /api -> backend:8080).
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copie du build Angular dans la racine servie par nginx.
COPY --from=build /app/dist/pdfeditor-frontend/browser /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

99
frontend/README.md Normal file
View File

@ -0,0 +1,99 @@
# PdfEditor — Frontend (Angular)
Application web d'édition de PDF auto-hébergée. Frontend Angular (standalone
components, routing, lazy-loading) consommant l'API REST décrite dans
`../docs/API.md`. L'API est toujours appelée en chemin **relatif** `/api/...`,
afin que le proxy nginx (prod) comme le proxy Angular (dev) fonctionnent sans
configuration d'URL.
## Prérequis
- Node.js 20+ (testé avec Node 22/24) et npm.
- Angular CLI (`npm install -g @angular/cli`) ou via `npx ng`.
## Installation
```bash
npm install
```
> Le fichier `package-lock.json` n'est pas fourni : il sera généré au premier
> `npm install`. Pensez à le commiter ensuite (le Dockerfile l'utilise via
> `npm ci` s'il est présent).
## Développement local (`ng serve` + proxy)
Le backend est exposé en local sur `http://localhost:8081` par
`../docker-compose.local.yml`. Le fichier `proxy.conf.json` redirige `/api`
vers ce port.
1. Démarrer la base + le backend :
```bash
# à la racine du dépôt
docker compose -f docker-compose.local.yml up --build db backend
```
2. Lancer le serveur de dev Angular (utilise automatiquement le proxy) :
```bash
npm start
# équivaut à : ng serve --proxy-config proxy.conf.json
```
3. Ouvrir http://localhost:4200.
## Build de production
```bash
npm run build
# Sortie : dist/pdfeditor-frontend/browser
```
## Docker
`Dockerfile` (multi-stage : build Angular puis service nginx) et `nginx.conf`
(SPA + proxy `/api` -> `http://backend:8080`) sont prévus pour
`../docker-compose.yml` et `../docker-compose.local.yml` (service `frontend`,
exposé sur le port 80 de l'image).
```bash
# build local de l'image (depuis le dossier frontend)
docker build -t pdfeditor-frontend .
```
## Architecture
```
src/app/
├── core/ # services transverses
│ ├── auth.service.ts # login/register/refresh/logout + état utilisateur
│ ├── auth.interceptor.ts # Bearer + refresh automatique sur 401
│ ├── auth.guard.ts # garde des routes protégées
│ ├── documents.service.ts # CRUD /api/documents
│ └── models.ts # types alignés sur le contrat d'API
└── features/
├── auth/ # pages login + register
├── dashboard/ # liste des documents sauvegardés
└── editor/ # éditeur PDF (cœur du produit)
├── editor.component.* # orchestration (drag&drop, toolbar, export)
├── page-layer.component.ts # rendu d'une page + couche d'annotations
├── pdf.service.ts # rendu pdf.js + export pdf-lib
├── signature-dialog.component.ts
└── annotation.model.ts
```
## Librairies clés
- **pdfjs-dist** : rendu des pages PDF dans un `<canvas>` (navigation, zoom).
- **pdf-lib** : aplatissement des annotations dans le PDF à l'export.
- **signature_pad** : capture de la signature à main levée.
- Couche d'annotations : **DOM/CSS maison** (pas de fabric/konva) — éléments
simples, édition de texte et rendu d'images natifs, dépendance évitée.
## Authentification
- `access_token` conservé **en mémoire** (perdu au rechargement, restauré par
un refresh) ; `refresh_token` persisté dans `localStorage`.
- L'intercepteur ajoute le Bearer et, sur `401`, tente un `/api/auth/refresh`
unique (les requêtes concurrentes attendent puis sont rejouées).

100
frontend/angular.json Normal file
View File

@ -0,0 +1,100 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"pdfeditor-frontend": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"standalone": true,
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/pdfeditor-frontend",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
},
{
"glob": "pdf.worker.min.mjs",
"input": "node_modules/pdfjs-dist/build",
"output": "assets"
}
],
"styles": ["src/styles.scss"],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "4mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "16kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "pdfeditor-frontend:build:production"
},
"development": {
"buildTarget": "pdfeditor-frontend:build:development"
}
},
"defaultConfiguration": "development",
"options": {
"proxyConfig": "proxy.conf.json"
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": ["zone.js", "zone.js/testing"],
"tsConfig": "tsconfig.spec.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": ["src/styles.scss"],
"scripts": []
}
}
}
}
}
}

BIN
frontend/irm-60.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

52
frontend/nginx.conf Normal file
View File

@ -0,0 +1,52 @@
# Configuration nginx servant l'application Angular et proxifiant l'API backend.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Taille max des uploads (PDF édités). Ajuster si besoin.
client_max_body_size 50m;
# Compression des assets statiques.
gzip on;
gzip_types text/plain text/css application/javascript application/json
image/svg+xml application/wasm;
gzip_min_length 1024;
# Proxy de toutes les requêtes /api/* vers le service backend (Docker).
location /api/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Téléchargements/uploads de PDF potentiellement longs.
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# Les modules ES (.mjs, ex. le worker pdf.js) ne sont pas dans la table MIME
# par défaut de nginx et sortiraient en application/octet-stream, ce que le
# navigateur refuse de charger comme module. On force le bon type.
location ~* \.mjs$ {
default_type application/javascript;
expires 30d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Cache long pour les assets versionnés (hash dans le nom de fichier).
location /assets/ {
try_files $uri =404;
expires 30d;
add_header Cache-Control "public, immutable";
}
# Fallback SPA : toute autre route renvoie index.html (routing Angular).
location / {
try_files $uri $uri/ /index.html;
}
}

16346
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
frontend/package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "pdfeditor-frontend",
"version": "1.0.0",
"description": "Frontend Angular de PdfEditor — éditeur de PDF web auto-hébergé.",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^19.2.0",
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.10.38",
"rxjs": "~7.8.0",
"signature_pad": "^5.0.4",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.0",
"@angular/cli": "^19.2.0",
"@angular/compiler-cli": "^19.2.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.4.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2"
}
}

8
frontend/proxy.conf.json Normal file
View File

@ -0,0 +1,8 @@
{
"/api": {
"target": "http://localhost:8081",
"secure": false,
"changeOrigin": true,
"logLevel": "debug"
}
}

View File

View File

@ -0,0 +1,82 @@
import { Component, inject } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { AsyncPipe } from '@angular/common';
import { AuthService } from './core/auth.service';
/**
* Composant racine : barre de navigation commune + zone de routage.
*/
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive, AsyncPipe],
template: `
<header class="barre-nav">
<a class="logo" routerLink="/editor">📄 PdfEditor</a>
<nav>
<a routerLink="/editor" routerLinkActive="actif">Éditeur</a>
@if (auth.estConnecte$ | async) {
<a routerLink="/dashboard" routerLinkActive="actif">Mes documents</a>
<span class="email">{{ (auth.utilisateur$ | async)?.email }}</span>
<button class="btn" (click)="deconnexion()">Déconnexion</button>
} @else {
<a routerLink="/login" routerLinkActive="actif">Connexion</a>
<a routerLink="/register" routerLinkActive="actif">Inscription</a>
}
</nav>
</header>
<main>
<router-outlet />
</main>
`,
styles: [
`
.barre-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 56px;
background: var(--couleur-surface);
border-bottom: 1px solid var(--couleur-bordure);
box-shadow: var(--ombre);
}
.logo {
font-weight: 700;
font-size: 18px;
color: var(--couleur-texte);
}
.logo:hover {
text-decoration: none;
}
nav {
display: flex;
align-items: center;
gap: 16px;
}
nav a {
color: var(--couleur-texte-doux);
font-size: 14px;
}
nav a.actif {
color: var(--couleur-primaire);
font-weight: 600;
}
.email {
font-size: 13px;
color: var(--couleur-texte-doux);
}
main {
height: calc(100vh - 56px);
overflow: auto;
}
`,
],
})
export class AppComponent {
protected readonly auth = inject(AuthService);
deconnexion(): void {
this.auth.logout().subscribe();
}
}

View File

@ -0,0 +1,19 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/auth.interceptor';
/**
* Configuration racine de l'application standalone.
* - Router avec les routes déclarées dans app.routes.ts
* - HttpClient muni de l'intercepteur d'authentification (Bearer + refresh sur 401)
*/
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
],
};

View File

@ -0,0 +1,53 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/auth.guard';
/**
* Définition des routes de l'application.
* Les pages protégées (dashboard, éditeur) sont gardées par authGuard.
* Tout est chargé en lazy-loading pour alléger le bundle initial.
*/
export const routes: Routes = [
{
path: 'login',
loadComponent: () =>
import('./features/auth/login.component').then((m) => m.LoginComponent),
title: 'Connexion — PdfEditor',
},
{
path: 'register',
loadComponent: () =>
import('./features/auth/register.component').then(
(m) => m.RegisterComponent,
),
title: 'Inscription — PdfEditor',
},
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () =>
import('./features/dashboard/dashboard.component').then(
(m) => m.DashboardComponent,
),
title: 'Mes documents — PdfEditor',
},
{
path: 'editor',
loadComponent: () =>
import('./features/editor/editor.component').then(
(m) => m.EditorComponent,
),
title: 'Éditeur — PdfEditor',
},
{
// Ouverture d'un document existant : /editor/:id
path: 'editor/:id',
canActivate: [authGuard],
loadComponent: () =>
import('./features/editor/editor.component').then(
(m) => m.EditorComponent,
),
title: 'Éditeur — PdfEditor',
},
{ path: '', redirectTo: 'editor', pathMatch: 'full' },
{ path: '**', redirectTo: 'editor' },
];

View File

@ -0,0 +1,53 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Observable, map, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AuthService } from './auth.service';
/**
* Garde de route pour les pages protégées.
*
* - Si un utilisateur est déjà chargé en mémoire -> accès autorisé.
* - Sinon, s'il existe un refresh_token persistant, on tente de restaurer la
* session (refresh + chargement du profil) : utile après un rechargement de page
* où l'access_token en mémoire a été perdu.
* - En cas d'échec, redirection vers /login en conservant l'URL demandée.
*/
export const authGuard: CanActivateFn = (_route, state): Observable<boolean> => {
const auth = inject(AuthService);
const router = inject(Router);
const versLogin = () =>
router.createUrlTree(['/login'], {
queryParams: { redirige: state.url },
});
// Utilisateur déjà en mémoire.
if (auth.getAccessToken()) {
return of(true);
}
// Tentative de restauration via le refresh_token persistant.
if (auth.aUneSessionPotentielle()) {
return auth.refresh().pipe(
// Après refresh on récupère le profil pour peupler le BehaviorSubject.
map(() => true),
// chargerProfil est déclenché en arrière-plan ; on autorise dès le refresh OK.
catchError(() => {
auth.viderSession();
router.navigateByUrl('');
return of(false);
}),
map((ok) => {
if (ok) {
auth.chargerProfil().subscribe({ error: () => {} });
}
return ok;
}),
);
}
// Aucune session : redirection.
router.navigateByUrl(versLogin().toString());
return of(false);
};

View File

@ -0,0 +1,107 @@
import {
HttpErrorResponse,
HttpEvent,
HttpHandlerFn,
HttpInterceptorFn,
HttpRequest,
} from '@angular/common/http';
import { inject } from '@angular/core';
import {
BehaviorSubject,
Observable,
catchError,
filter,
switchMap,
take,
throwError,
} from 'rxjs';
import { AuthService } from './auth.service';
/**
* Intercepteur HTTP :
* 1. Ajoute l'en-tête `Authorization: Bearer <access_token>` aux requêtes /api
* (sauf aux endpoints d'authentification eux-mêmes).
* 2. Sur une réponse 401, tente UN refresh puis rejoue la requête. Les requêtes
* concurrentes pendant le refresh sont mises en file et rejouées une fois le
* nouveau token disponible.
*/
// État partagé du rafraîchissement (au niveau module : un seul refresh à la fois).
let refreshEnCours = false;
const tokenRafraichi$ = new BehaviorSubject<string | null>(null);
/** Endpoints qui ne doivent pas porter de Bearer ni déclencher de refresh. */
const ENDPOINTS_AUTH = ['/api/auth/login', '/api/auth/register', '/api/auth/refresh'];
function estEndpointAuth(url: string): boolean {
return ENDPOINTS_AUTH.some((e) => url.includes(e));
}
function ajouterToken(
req: HttpRequest<unknown>,
token: string,
): HttpRequest<unknown> {
return req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
}
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
// Les endpoints d'auth passent tels quels.
if (estEndpointAuth(req.url)) {
return next(req);
}
const accessToken = auth.getAccessToken();
const requete = accessToken ? ajouterToken(req, accessToken) : req;
return next(requete).pipe(
catchError((erreur: unknown) => {
if (
erreur instanceof HttpErrorResponse &&
erreur.status === 401 &&
auth.aUneSessionPotentielle()
) {
return gererErreur401(req, next, auth);
}
return throwError(() => erreur);
}),
);
};
/**
* Gère un 401 : déclenche (ou attend) un refresh, puis rejoue la requête.
*/
function gererErreur401(
req: HttpRequest<unknown>,
next: HttpHandlerFn,
auth: AuthService,
): Observable<HttpEvent<unknown>> {
if (refreshEnCours) {
// Un refresh est déjà en cours : on attend le nouveau token puis on rejoue.
return tokenRafraichi$.pipe(
filter((token): token is string => token !== null),
take(1),
switchMap((token) => next(ajouterToken(req, token))),
);
}
refreshEnCours = true;
tokenRafraichi$.next(null);
return auth.refresh().pipe(
switchMap((res) => {
refreshEnCours = false;
tokenRafraichi$.next(res.access_token);
return next(ajouterToken(req, res.access_token));
}),
catchError((erreur: unknown) => {
// Le refresh a échoué : session invalide, on purge et on propage l'erreur.
refreshEnCours = false;
auth.viderSession();
return throwError(() => erreur);
}),
);
}

View File

@ -0,0 +1,123 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, map, tap } from 'rxjs';
import { Router } from '@angular/router';
import { ReponseAuth, ReponseRefresh, Utilisateur } from './models';
/**
* Service central d'authentification.
*
* Stratégie de stockage (cf. contrat d'API) :
* - access_token : conservé EN MÉMOIRE uniquement (variable privée), jamais persisté
* pour limiter l'exposition en cas de XSS.
* - refresh_token : persisté dans localStorage afin de rétablir la session au
* rechargement de la page.
*/
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
private static readonly CLE_REFRESH = 'pdfeditor_refresh_token';
/** access_token en mémoire (perdu au rechargement, restauré via refresh) */
private accessToken: string | null = null;
private readonly utilisateurSubject =
new BehaviorSubject<Utilisateur | null>(null);
readonly utilisateur$ = this.utilisateurSubject.asObservable();
readonly estConnecte$ = this.utilisateur$.pipe(map((u) => u !== null));
/** Indique si l'on possède un refresh_token persistant (session potentielle). */
aUneSessionPotentielle(): boolean {
return this.getRefreshToken() !== null;
}
getAccessToken(): string | null {
return this.accessToken;
}
getRefreshToken(): string | null {
return localStorage.getItem(AuthService.CLE_REFRESH);
}
// --- Endpoints d'authentification --------------------------------------
register(email: string, password: string): Observable<ReponseAuth> {
return this.http
.post<ReponseAuth>('/api/auth/register', { email, password })
.pipe(tap((res) => this.appliquerSession(res)));
}
login(email: string, password: string): Observable<ReponseAuth> {
return this.http
.post<ReponseAuth>('/api/auth/login', { email, password })
.pipe(tap((res) => this.appliquerSession(res)));
}
/**
* Rafraîchit le couple de tokens à partir du refresh_token persistant.
* Utilisé par l'intercepteur sur 401 et au démarrage de l'app.
*/
refresh(): Observable<ReponseRefresh> {
const refreshToken = this.getRefreshToken();
return this.http
.post<ReponseRefresh>('/api/auth/refresh', {
refresh_token: refreshToken,
})
.pipe(
tap((res) => {
this.accessToken = res.access_token;
this.enregistrerRefreshToken(res.refresh_token);
}),
);
}
/** Récupère le profil courant (utilisé après un refresh au démarrage). */
chargerProfil(): Observable<Utilisateur> {
return this.http
.get<Utilisateur>('/api/me')
.pipe(tap((u) => this.utilisateurSubject.next(u)));
}
logout(): Observable<void> {
// On tente d'invalider le refresh côté serveur ; quoi qu'il arrive on purge en local.
const finaliser = () => {
this.viderSession();
this.router.navigate(['/login']);
};
return new Observable<void>((observer) => {
this.http.post<void>('/api/auth/logout', {}).subscribe({
next: () => {
finaliser();
observer.next();
observer.complete();
},
error: () => {
finaliser();
observer.next();
observer.complete();
},
});
});
}
// --- Helpers internes ---------------------------------------------------
private appliquerSession(res: ReponseAuth): void {
this.accessToken = res.access_token;
this.enregistrerRefreshToken(res.refresh_token);
this.utilisateurSubject.next(res.user);
}
private enregistrerRefreshToken(token: string): void {
localStorage.setItem(AuthService.CLE_REFRESH, token);
}
/** Efface toute trace de session (utilisé au logout et sur refresh échoué). */
viderSession(): void {
this.accessToken = null;
localStorage.removeItem(AuthService.CLE_REFRESH);
this.utilisateurSubject.next(null);
}
}

View File

@ -0,0 +1,60 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { DocumentPdf, ReponseDocuments } from './models';
/**
* Service d'accès aux documents PDF de l'utilisateur (CRUD via /api/documents).
*/
@Injectable({ providedIn: 'root' })
export class DocumentsService {
private readonly http = inject(HttpClient);
/** Liste les documents de l'utilisateur connecté. */
lister(): Observable<DocumentPdf[]> {
return this.http
.get<ReponseDocuments>('/api/documents')
.pipe(map((r) => r.documents));
}
/** Métadonnées d'un document. */
obtenir(id: string): Observable<DocumentPdf> {
return this.http.get<DocumentPdf>(`/api/documents/${id}`);
}
/** Télécharge le binaire PDF d'un document. */
telechargerFichier(id: string): Observable<ArrayBuffer> {
return this.http.get(`/api/documents/${id}/file`, {
responseType: 'arraybuffer',
});
}
/** Upload d'un nouveau PDF. */
creer(fichier: Blob, nom: string): Observable<DocumentPdf> {
const form = new FormData();
form.append('file', fichier, nom);
form.append('name', nom);
return this.http.post<DocumentPdf>('/api/documents', form);
}
/** Met à jour le binaire et/ou le nom d'un document existant. */
mettreAJour(
id: string,
fichier: Blob | null,
nom: string | null,
): Observable<DocumentPdf> {
const form = new FormData();
if (fichier) {
form.append('file', fichier, nom ?? 'document.pdf');
}
if (nom) {
form.append('name', nom);
}
return this.http.put<DocumentPdf>(`/api/documents/${id}`, form);
}
/** Supprime un document. */
supprimer(id: string): Observable<void> {
return this.http.delete<void>(`/api/documents/${id}`);
}
}

View File

@ -0,0 +1,35 @@
/**
* Modèles de données alignés sur le contrat d'API (docs/API.md).
*/
export interface Utilisateur {
id: string;
email: string;
created_at: string;
}
/** Réponse des endpoints /auth/login et /auth/register */
export interface ReponseAuth {
user: Utilisateur;
access_token: string;
refresh_token: string;
}
/** Réponse de /auth/refresh */
export interface ReponseRefresh {
access_token: string;
refresh_token: string;
}
/** Métadonnées d'un document PDF */
export interface DocumentPdf {
id: string;
name: string;
size: number;
created_at: string;
updated_at: string;
}
export interface ReponseDocuments {
documents: DocumentPdf[];
}

View File

@ -0,0 +1,36 @@
/* Styles partagés des pages login / register */
:host {
display: flex;
justify-content: center;
align-items: flex-start;
padding: 48px 16px;
}
.carte-auth {
width: 100%;
max-width: 380px;
background: var(--couleur-surface);
border: 1px solid var(--couleur-bordure);
border-radius: var(--rayon);
box-shadow: var(--ombre);
padding: 28px;
}
.carte-auth h1 {
margin: 0 0 20px;
font-size: 22px;
}
.btn.bloc {
width: 100%;
justify-content: center;
margin-top: 6px;
}
.lien-secondaire {
margin-top: 18px;
font-size: 14px;
text-align: center;
color: var(--couleur-texte-doux);
}

View File

@ -0,0 +1,90 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../core/auth.service';
/**
* Page de connexion. Consomme /api/auth/login via AuthService.
*/
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule, RouterLink],
template: `
<div class="carte-auth">
<h1>Connexion</h1>
<form (ngSubmit)="soumettre()">
<div class="champ">
<label for="email">Adresse e-mail</label>
<input
id="email"
name="email"
type="email"
[(ngModel)]="email"
required
autocomplete="email"
/>
</div>
<div class="champ">
<label for="password">Mot de passe</label>
<input
id="password"
name="password"
type="password"
[(ngModel)]="password"
required
autocomplete="current-password"
/>
</div>
@if (erreur()) {
<p class="message-erreur">{{ erreur() }}</p>
}
<button
class="btn btn-primaire bloc"
type="submit"
[disabled]="chargement()"
>
{{ chargement() ? 'Connexion…' : 'Se connecter' }}
</button>
</form>
<p class="lien-secondaire">
Pas de compte ? <a routerLink="/register">Créer un compte</a>
</p>
</div>
`,
styleUrl: './auth.scss',
})
export class LoginComponent {
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
email = '';
password = '';
readonly chargement = signal(false);
readonly erreur = signal<string | null>(null);
soumettre(): void {
if (!this.email || !this.password) {
return;
}
this.chargement.set(true);
this.erreur.set(null);
this.auth.login(this.email, this.password).subscribe({
next: () => {
const redirige =
this.route.snapshot.queryParamMap.get('redirige') ?? '/dashboard';
this.router.navigateByUrl(redirige);
},
error: (e: HttpErrorResponse) => {
this.chargement.set(false);
this.erreur.set(
e.error?.error ?? 'Connexion impossible. Vérifiez vos identifiants.',
);
},
});
}
}

View File

@ -0,0 +1,109 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../core/auth.service';
/**
* Page d'inscription. Consomme /api/auth/register via AuthService.
*/
@Component({
selector: 'app-register',
standalone: true,
imports: [FormsModule, RouterLink],
template: `
<div class="carte-auth">
<h1>Créer un compte</h1>
<form (ngSubmit)="soumettre()">
<div class="champ">
<label for="email">Adresse e-mail</label>
<input
id="email"
name="email"
type="email"
[(ngModel)]="email"
required
autocomplete="email"
/>
</div>
<div class="champ">
<label for="password">Mot de passe</label>
<input
id="password"
name="password"
type="password"
[(ngModel)]="password"
required
minlength="8"
autocomplete="new-password"
/>
</div>
<div class="champ">
<label for="confirmation">Confirmer le mot de passe</label>
<input
id="confirmation"
name="confirmation"
type="password"
[(ngModel)]="confirmation"
required
autocomplete="new-password"
/>
</div>
@if (erreur()) {
<p class="message-erreur">{{ erreur() }}</p>
}
<button
class="btn btn-primaire bloc"
type="submit"
[disabled]="chargement()"
>
{{ chargement() ? 'Création…' : "S'inscrire" }}
</button>
</form>
<p class="lien-secondaire">
Déjà inscrit ? <a routerLink="/login">Se connecter</a>
</p>
</div>
`,
styleUrl: './auth.scss',
})
export class RegisterComponent {
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
email = '';
password = '';
confirmation = '';
readonly chargement = signal(false);
readonly erreur = signal<string | null>(null);
soumettre(): void {
if (!this.email || !this.password) {
return;
}
if (this.password.length < 8) {
this.erreur.set('Le mot de passe doit contenir au moins 8 caractères.');
return;
}
if (this.password !== this.confirmation) {
this.erreur.set('Les mots de passe ne correspondent pas.');
return;
}
this.chargement.set(true);
this.erreur.set(null);
this.auth.register(this.email, this.password).subscribe({
next: () => this.router.navigateByUrl('/dashboard'),
error: (e: HttpErrorResponse) => {
this.chargement.set(false);
this.erreur.set(
e.error?.error ??
(e.status === 409
? 'Cette adresse e-mail est déjà utilisée.'
: "Inscription impossible. Réessayez."),
);
},
});
}
}

View File

@ -0,0 +1,187 @@
import { Component, inject, signal } from '@angular/core';
import { DatePipe, DecimalPipe } from '@angular/common';
import { Router } from '@angular/router';
import { DocumentsService } from '../../core/documents.service';
import { DocumentPdf } from '../../core/models';
/**
* Tableau de bord : liste les PDF sauvegardés de l'utilisateur.
* Permet d'ouvrir un document dans l'éditeur ou de le supprimer.
*/
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [DatePipe, DecimalPipe],
template: `
<div class="conteneur">
<header class="entete">
<h1>Mes documents</h1>
<button class="btn btn-primaire" (click)="nouveau()">
+ Nouveau document
</button>
</header>
@if (chargement()) {
<p class="info">Chargement…</p>
} @else if (erreur()) {
<p class="message-erreur">{{ erreur() }}</p>
} @else if (documents().length === 0) {
<div class="vide">
<p>Aucun document sauvegardé pour le moment.</p>
<button class="btn btn-primaire" (click)="nouveau()">
Charger un PDF
</button>
</div>
} @else {
<table class="tableau">
<thead>
<tr>
<th>Nom</th>
<th>Taille</th>
<th>Modifié le</th>
<th></th>
</tr>
</thead>
<tbody>
@for (doc of documents(); track doc.id) {
<tr>
<td class="nom" (click)="ouvrir(doc)">📄 {{ doc.name }}</td>
<td>{{ doc.size / 1024 | number: '1.0-0' }} Ko</td>
<td>{{ doc.updated_at | date: 'dd/MM/yyyy HH:mm' }}</td>
<td class="actions">
<button class="btn" (click)="ouvrir(doc)">Ouvrir</button>
<button
class="btn btn-danger"
(click)="supprimer(doc)"
[disabled]="suppressionEnCours() === doc.id"
>
Supprimer
</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
`,
styles: [
`
.conteneur {
max-width: 900px;
margin: 0 auto;
padding: 28px 20px;
}
.entete {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.entete h1 {
margin: 0;
font-size: 24px;
}
.info {
color: var(--couleur-texte-doux);
}
.vide {
text-align: center;
padding: 60px 20px;
background: var(--couleur-surface);
border: 1px dashed var(--couleur-bordure);
border-radius: var(--rayon);
}
.vide p {
color: var(--couleur-texte-doux);
margin-bottom: 16px;
}
.tableau {
width: 100%;
border-collapse: collapse;
background: var(--couleur-surface);
border-radius: var(--rayon);
overflow: hidden;
box-shadow: var(--ombre);
}
.tableau th,
.tableau td {
text-align: left;
padding: 12px 16px;
border-bottom: 1px solid var(--couleur-bordure);
font-size: 14px;
}
.tableau th {
background: #f8fafc;
color: var(--couleur-texte-doux);
font-weight: 600;
}
.nom {
cursor: pointer;
font-weight: 600;
}
.nom:hover {
color: var(--couleur-primaire);
}
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
`,
],
})
export class DashboardComponent {
private readonly documentsService = inject(DocumentsService);
private readonly router = inject(Router);
readonly documents = signal<DocumentPdf[]>([]);
readonly chargement = signal(true);
readonly erreur = signal<string | null>(null);
readonly suppressionEnCours = signal<string | null>(null);
constructor() {
this.recharger();
}
recharger(): void {
this.chargement.set(true);
this.documentsService.lister().subscribe({
next: (docs) => {
this.documents.set(docs);
this.chargement.set(false);
},
error: () => {
this.erreur.set('Impossible de charger vos documents.');
this.chargement.set(false);
},
});
}
nouveau(): void {
this.router.navigate(['/editor']);
}
ouvrir(doc: DocumentPdf): void {
this.router.navigate(['/editor', doc.id]);
}
supprimer(doc: DocumentPdf): void {
if (!confirm(`Supprimer définitivement « ${doc.name} » ?`)) {
return;
}
this.suppressionEnCours.set(doc.id);
this.documentsService.supprimer(doc.id).subscribe({
next: () => {
this.documents.update((liste) =>
liste.filter((d) => d.id !== doc.id),
);
this.suppressionEnCours.set(null);
},
error: () => {
this.erreur.set('La suppression a échoué.');
this.suppressionEnCours.set(null);
},
});
}
}

View File

@ -0,0 +1,82 @@
/**
* Modèles des annotations posées sur la couche d'édition.
*
* Toutes les coordonnées (x, y, w, h) sont exprimées en POURCENTAGE [0..1]
* de la taille de la page rendue. Cela rend les annotations indépendantes du
* zoom : on les stocke en relatif et on les convertit en pixels (pour
* l'affichage) ou en points PDF (pour l'export pdf-lib) au moment voulu.
*/
export type TypeAnnotation =
| 'texte'
| 'image'
| 'signature'
| 'rectangle'
| 'cercle'
| 'ligne'
| 'fleche'
| 'croix'
| 'lien';
export interface AnnotationBase {
id: string;
type: TypeAnnotation;
page: number; // index de page (0-based)
x: number; // coin haut-gauche, en fraction de la largeur
y: number; // coin haut-gauche, en fraction de la hauteur
w: number; // largeur en fraction
h: number; // hauteur en fraction
}
export interface AnnotationTexte extends AnnotationBase {
type: 'texte';
texte: string;
taillePolice: number; // en pixels à 100% de zoom
couleur: string; // #rrggbb
}
export interface AnnotationImage extends AnnotationBase {
type: 'image' | 'signature';
/** Image encodée en data URL (png/jpeg). */
dataUrl: string;
}
export interface AnnotationForme extends AnnotationBase {
type: 'rectangle' | 'cercle' | 'ligne' | 'fleche' | 'croix';
couleur: string; // trait
epaisseur: number; // en pixels à 100%
remplissage?: string | null; // couleur de fond (rect/cercle), sinon null
}
export interface AnnotationLien extends AnnotationBase {
type: 'lien';
url: string;
}
export type Annotation =
| AnnotationTexte
| AnnotationImage
| AnnotationForme
| AnnotationLien;
/** Outils sélectionnables dans la barre d'outils de l'éditeur. */
export type Outil =
| 'selection'
| 'texte'
| 'image'
| 'signature'
| 'rectangle'
| 'cercle'
| 'ligne'
| 'fleche'
| 'croix'
| 'lien';
/** Liste des outils « forme » regroupés dans le menu déroulant de la barre d'outils. */
export const FORMES: { outil: Outil; icone: string; libelle: string }[] = [
{ outil: 'rectangle', icone: '▭', libelle: 'Rectangle' },
{ outil: 'cercle', icone: '◯', libelle: 'Cercle' },
{ outil: 'ligne', icone: '', libelle: 'Ligne' },
{ outil: 'fleche', icone: '➚', libelle: 'Flèche' },
{ outil: 'croix', icone: '✕', libelle: 'Croix' },
];

View File

@ -0,0 +1,114 @@
<div class="editeur">
@if (!pdf()) {
<!-- Zone de glisser-déposer initiale -->
<div
class="depot"
[class.survol]="survolDepot()"
(dragover)="empecherDefaut($event)"
(dragleave)="finSurvol($event)"
(drop)="surFichierDepose($event)"
>
<div class="depot-contenu">
<div class="icone">📄</div>
<h2>Glissez-déposez un PDF ici</h2>
<p>ou</p>
<label class="btn btn-primaire">
Parcourir un fichier
<input
type="file"
accept="application/pdf"
hidden
(change)="surFichierChoisi($event)"
/>
</label>
@if (message()) {
<p class="message-erreur">{{ message() }}</p>
}
</div>
</div>
} @else {
<!-- Barre d'outils -->
<div class="barre-outils">
<div class="groupe">
<button class="btn" [class.actif]="outil() === 'selection'" (click)="choisirOutil('selection')" title="Sélection"></button>
<button class="btn" [class.actif]="outil() === 'texte'" (click)="choisirOutil('texte')" title="Texte">T</button>
<label class="btn" title="Image">
🖼
<input type="file" accept="image/*" hidden (change)="surImageChoisie($event)" />
</label>
<button class="btn" [class.actif]="outil() === 'signature'" (click)="choisirOutil('signature')" title="Signature"></button>
<!-- Menu déroulant des formes -->
<div class="menu-formes">
<button class="btn" [class.actif]="estForme(outil())" (click)="basculerMenuFormes()" title="Formes">
{{ iconeForme(formeActive()) }} ▾
</button>
@if (menuFormesOuvert()) {
<div class="menu-deroulant">
@for (f of formes; track f.outil) {
<button class="item" [class.actif]="outil() === f.outil" (click)="choisirForme(f.outil)">
<span class="ic">{{ f.icone }}</span> {{ f.libelle }}
</button>
}
</div>
}
</div>
<button class="btn" [class.actif]="outil() === 'lien'" (click)="choisirOutil('lien')" title="Lien">🔗</button>
<input
class="selecteur-couleur"
type="color"
[value]="couleur()"
(input)="couleur.set($any($event.target).value)"
title="Couleur"
/>
</div>
<div class="groupe">
<button class="btn" (click)="zoomMoins()" title="Dézoomer"></button>
<span class="zoom-valeur">{{ (zoom() * 100).toFixed(0) }} %</span>
<button class="btn" (click)="zoomPlus()" title="Zoomer">+</button>
<span class="zoom-valeur" title="version">v16</span>
</div>
<div class="groupe">
<button class="btn" (click)="telecharger()">⬇ Télécharger</button>
@if (estConnecte$ | async) {
<button class="btn btn-primaire" (click)="sauvegarder()" [disabled]="chargement()">
💾 Sauvegarder
</button>
} @else {
<button class="btn" (click)="allerConnexion()">Se connecter pour sauvegarder</button>
}
</div>
</div>
@if (message()) {
<p class="bandeau">{{ message() }}</p>
}
<!-- Pages -->
<div class="zone-pages">
@for (i of pages(); track i) {
<app-page-layer
[pdf]="pdf()!"
[pageIndex]="i"
[zoom]="zoom()"
[outil]="outil()"
[couleur]="couleur()"
[chargeUtile]="chargeUtile()"
[annotations]="annotations()"
(annotationsChange)="majAnnotations($event)"
(outilConsomme)="outilConsomme()"
/>
}
</div>
}
@if (afficheSignature()) {
<app-signature-dialog
(valide)="signatureValidee($event)"
(annule)="signatureAnnulee()"
/>
}
</div>

View File

@ -0,0 +1,138 @@
.editeur {
display: flex;
flex-direction: column;
height: 100%;
}
/* --- Zone de dépôt initiale --- */
.depot {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
margin: 24px;
border: 2px dashed var(--couleur-bordure);
border-radius: 12px;
background: var(--couleur-surface);
transition: border-color 0.15s, background 0.15s;
}
.depot.survol {
border-color: var(--couleur-primaire);
background: #eef3ff;
}
.depot-contenu {
text-align: center;
}
.depot-contenu .icone {
font-size: 56px;
}
.depot-contenu h2 {
margin: 12px 0 4px;
}
.depot-contenu p {
color: var(--couleur-texte-doux);
margin: 6px 0 14px;
}
/* --- Barre d'outils --- */
.barre-outils {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 10px 16px;
background: var(--couleur-surface);
border-bottom: 1px solid var(--couleur-bordure);
}
.groupe {
display: flex;
align-items: center;
gap: 6px;
}
.barre-outils .btn {
padding: 7px 10px;
min-width: 36px;
justify-content: center;
}
.barre-outils .btn.actif {
background: var(--couleur-primaire);
color: #fff;
border-color: var(--couleur-primaire);
}
.menu-formes {
position: relative;
display: inline-flex;
}
.menu-deroulant {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 20;
min-width: 150px;
background: var(--couleur-surface);
border: 1px solid var(--couleur-bordure);
border-radius: var(--rayon);
box-shadow: var(--ombre);
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
.menu-deroulant .item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: none;
background: transparent;
border-radius: var(--rayon);
cursor: pointer;
text-align: left;
font: inherit;
color: inherit;
}
.menu-deroulant .item:hover {
background: var(--couleur-fond, rgba(0, 0, 0, 0.05));
}
.menu-deroulant .item.actif {
background: var(--couleur-primaire);
color: #fff;
}
.menu-deroulant .item .ic {
width: 18px;
text-align: center;
}
.selecteur-couleur {
width: 36px;
height: 34px;
padding: 2px;
border: 1px solid var(--couleur-bordure);
border-radius: var(--rayon);
cursor: pointer;
background: #fff;
}
.zoom-valeur {
min-width: 52px;
text-align: center;
font-size: 13px;
color: var(--couleur-texte-doux);
}
.bandeau {
margin: 0;
padding: 8px 16px;
background: #eef3ff;
color: var(--couleur-primaire);
font-size: 13px;
border-bottom: 1px solid var(--couleur-bordure);
}
/* --- Zone des pages --- */
.zone-pages {
flex: 1;
overflow: auto;
padding: 24px;
background: #e9edf5;
}

View File

@ -0,0 +1,292 @@
import { Component, inject, signal } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import type { PDFDocumentProxy } from 'pdfjs-dist';
import { PdfService } from './pdf.service';
import { DocumentsService } from '../../core/documents.service';
import { AuthService } from '../../core/auth.service';
import { Annotation, Outil, FORMES } from './annotation.model';
import { PageLayerComponent } from './page-layer.component';
import { SignatureDialogComponent } from './signature-dialog.component';
/**
* Composant principal de l'éditeur : chargement (drag & drop ou document
* existant), barre d'outils, navigation/zoom, couches d'édition par page,
* puis export (téléchargement) et sauvegarde (API).
*/
@Component({
selector: 'app-editor',
standalone: true,
imports: [AsyncPipe, PageLayerComponent, SignatureDialogComponent],
templateUrl: './editor.component.html',
styleUrl: './editor.component.scss',
})
export class EditorComponent {
private readonly pdfService = inject(PdfService);
private readonly documentsService = inject(DocumentsService);
private readonly auth = inject(AuthService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
/** Binaire du PDF source (conservé pour l'export pdf-lib). */
private sourcePdf: ArrayBuffer | null = null;
/** Document pdf.js pour le rendu. */
readonly pdf = signal<PDFDocumentProxy | null>(null);
readonly nbPages = signal(0);
readonly nomDocument = signal('document.pdf');
/** Identifiant du document si ouvert depuis le dashboard (mode mise à jour). */
private documentId: string | null = null;
readonly annotations = signal<Annotation[]>([]);
readonly outil = signal<Outil>('selection');
readonly couleur = signal('#000000');
readonly zoom = signal(1);
/** Outils « forme » du menu déroulant. */
readonly formes = FORMES;
/** Dernière forme choisie (affichée sur le bouton du menu). */
readonly formeActive = signal<Outil>('rectangle');
readonly menuFormesOuvert = signal(false);
/** Charge utile pour les outils image/signature (data URL à poser). */
readonly chargeUtile = signal<string | null>(null);
readonly afficheSignature = signal(false);
readonly chargement = signal(false);
readonly message = signal<string | null>(null);
readonly survolDepot = signal(false);
readonly estConnecte$ = this.auth.estConnecte$;
/** Pages itérables pour le template. */
pages = (): number[] =>
Array.from({ length: this.nbPages() }, (_, i) => i);
constructor() {
const id = this.route.snapshot.paramMap.get('id');
if (id) {
this.documentId = id;
this.ouvrirDocumentExistant(id);
}
}
// --- Chargement ---------------------------------------------------------
private async ouvrirDocumentExistant(id: string): Promise<void> {
this.chargement.set(true);
this.documentsService.obtenir(id).subscribe({
next: (meta) => this.nomDocument.set(meta.name),
error: () => {},
});
this.documentsService.telechargerFichier(id).subscribe({
next: async (buffer) => {
await this.chargerBuffer(buffer);
this.chargement.set(false);
},
error: () => {
this.message.set("Impossible d'ouvrir ce document.");
this.chargement.set(false);
},
});
}
async surFichierDepose(ev: DragEvent): Promise<void> {
ev.preventDefault();
this.survolDepot.set(false);
const fichier = ev.dataTransfer?.files?.[0];
if (fichier) {
await this.chargerFichier(fichier);
}
}
async surFichierChoisi(ev: Event): Promise<void> {
const fichier = (ev.target as HTMLInputElement).files?.[0];
if (fichier) {
await this.chargerFichier(fichier);
}
}
private async chargerFichier(fichier: File): Promise<void> {
if (fichier.type !== 'application/pdf') {
this.message.set('Veuillez déposer un fichier PDF.');
return;
}
this.nomDocument.set(fichier.name);
this.documentId = null; // nouveau fichier => création à la sauvegarde
const buffer = await fichier.arrayBuffer();
await this.chargerBuffer(buffer);
}
private async chargerBuffer(buffer: ArrayBuffer): Promise<void> {
this.message.set(null);
this.sourcePdf = buffer.slice(0);
const pdf = await this.pdfService.charger(buffer);
this.pdf.set(pdf);
this.nbPages.set(pdf.numPages);
this.annotations.set([]);
}
empecherDefaut(ev: DragEvent): void {
ev.preventDefault();
this.survolDepot.set(true);
}
finSurvol(ev: DragEvent): void {
ev.preventDefault();
this.survolDepot.set(false);
}
// --- Barre d'outils -----------------------------------------------------
choisirOutil(o: Outil): void {
this.menuFormesOuvert.set(false);
this.outil.set(o);
if (o === 'signature') {
this.afficheSignature.set(true);
}
}
// --- Menu des formes ----------------------------------------------------
/** Vrai si l'outil donné est une forme (pour l'état actif du bouton menu). */
estForme(o: Outil): boolean {
return this.formes.some((f) => f.outil === o);
}
/** Icône associée à un outil forme. */
iconeForme(o: Outil): string {
return this.formes.find((f) => f.outil === o)?.icone ?? '▭';
}
basculerMenuFormes(): void {
this.menuFormesOuvert.update((v) => !v);
}
/** Choix d'une forme dans le menu déroulant. */
choisirForme(o: Outil): void {
this.formeActive.set(o);
this.menuFormesOuvert.set(false);
this.outil.set(o);
}
/** Déclenché par le sélecteur de fichier image caché. */
async surImageChoisie(ev: Event): Promise<void> {
const fichier = (ev.target as HTMLInputElement).files?.[0];
if (!fichier) {
return;
}
const dataUrl = await this.lireDataUrl(fichier);
this.chargeUtile.set(dataUrl);
this.outil.set('image');
this.message.set(
'Cliquez sur la page pour poser limage.',
);
}
signatureValidee(dataUrl: string): void {
this.chargeUtile.set(dataUrl);
this.outil.set('signature');
this.afficheSignature.set(false);
this.message.set('Cliquez sur la page pour poser la signature.');
}
signatureAnnulee(): void {
this.afficheSignature.set(false);
this.outil.set('selection');
}
outilConsomme(): void {
// Après pose d'un élément ponctuel, on revient en sélection.
this.outil.set('selection');
this.chargeUtile.set(null);
this.message.set(null);
}
majAnnotations(liste: Annotation[]): void {
this.annotations.set(liste);
}
// --- Zoom / navigation --------------------------------------------------
zoomPlus(): void {
this.zoom.update((z) => Math.min(3, +(z + 0.2).toFixed(2)));
}
zoomMoins(): void {
this.zoom.update((z) => Math.max(0.4, +(z - 0.2).toFixed(2)));
}
// --- Export / sauvegarde ------------------------------------------------
private async genererPdf(): Promise<Uint8Array> {
if (!this.sourcePdf) {
throw new Error('Aucun PDF chargé');
}
return this.pdfService.exporter(this.sourcePdf, this.annotations());
}
async telecharger(): Promise<void> {
try {
const octets = await this.genererPdf();
const blob = new Blob([octets as BlobPart], {
type: 'application/pdf',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = this.nomDocument().endsWith('.pdf')
? this.nomDocument()
: `${this.nomDocument()}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch {
this.message.set("L'export a échoué.");
}
}
async sauvegarder(): Promise<void> {
this.chargement.set(true);
this.message.set(null);
try {
const octets = await this.genererPdf();
const blob = new Blob([octets as BlobPart], {
type: 'application/pdf',
});
const nom = this.nomDocument();
const requete = this.documentId
? this.documentsService.mettreAJour(this.documentId, blob, nom)
: this.documentsService.creer(blob, nom);
requete.subscribe({
next: (doc) => {
this.documentId = doc.id;
this.chargement.set(false);
this.message.set('Document sauvegardé.');
},
error: () => {
this.chargement.set(false);
this.message.set('La sauvegarde a échoué.');
},
});
} catch {
this.chargement.set(false);
this.message.set("Impossible de générer le PDF.");
}
}
allerConnexion(): void {
this.router.navigate(['/login'], {
queryParams: { redirige: this.router.url },
});
}
// --- Helpers ------------------------------------------------------------
private lireDataUrl(fichier: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(fichier);
});
}
}

View File

@ -0,0 +1,743 @@
import {
AfterViewInit,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
Output,
SimpleChanges,
ViewChild,
inject,
} from '@angular/core';
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
import { PdfService } from './pdf.service';
import {
Annotation,
AnnotationForme,
AnnotationImage,
AnnotationLien,
AnnotationTexte,
Outil,
} from './annotation.model';
/** Identifiant unique simple pour les annotations. */
function nouvelId(): string {
return Math.random().toString(36).slice(2, 10);
}
/**
* Affiche UNE page de PDF (canvas pdf.js) surmontée d'une couche d'édition
* interactive (DOM positionné en absolu).
*
* Choix d'implémentation : couche DOM/CSS maison plutôt qu'une lib type
* fabric/konva. Les éléments à manipuler restent simples (texte, image, formes,
* liens) et le DOM offre nativement l'édition de texte, le rendu d'images et la
* sélection ; cela évite une dépendance lourde et garde le code lisible.
*/
@Component({
selector: 'app-page-layer',
standalone: true,
template: `
<div
class="page"
#conteneur
[style.width.px]="largeur"
[style.height.px]="hauteur"
(mousedown)="debutInteraction($event)"
>
<canvas #canvas class="rendu"></canvas>
<!-- Couche d'édition -->
<div class="couche">
<!-- DEBUG TEMP : repère BLEU (DOM) 30%-70% -->
<div style="position:absolute; left:30%; top:30%; width:40%; height:40%; border:2px solid blue; pointer-events:none; box-sizing:border-box;"></div>
@if (pageIndex === 0) {
<div style="position:fixed; top:96px; left:8px; z-index:9999; background:#ff0; color:#000; font:11px monospace; padding:4px 6px; border:1px solid #000; pointer-events:none; white-space:pre;">{{ dbg }}</div>
}
@for (a of annotationsPage(); track a.id) {
<div
class="annotation"
[class.selectionnee]="a.id === selectionId"
[style.left.px]="a.x * largeur"
[style.top.px]="a.y * hauteur"
[style.width.px]="a.w * largeur"
[style.height.px]="a.h * hauteur"
(mousedown)="debutDeplacement($event, a)"
>
@switch (a.type) {
@case ('texte') {
<div
class="contenu-texte"
[class.edition]="a.id === editionId"
[attr.contenteditable]="a.id === editionId"
[style.color]="texte(a).couleur"
[style.fontSize.px]="texte(a).taillePolice * zoom"
(dblclick)="entrerEdition(a, $event)"
(blur)="finEdition(a, $event)"
(mousedown)="a.id === editionId && $event.stopPropagation()"
>{{ texte(a).texte }}</div>
}
@case ('image') {
<img class="contenu-image" [src]="image(a).dataUrl" alt="" />
}
@case ('signature') {
<img class="contenu-image" [src]="image(a).dataUrl" alt="signature" />
}
@case ('lien') {
<div class="contenu-lien" [title]="lien(a).url">🔗 {{ lien(a).url }}</div>
}
@default {
<!-- formes : rectangle / cercle / ligne / flèche dessinées en SVG -->
<svg class="contenu-forme" [attr.viewBox]="'0 0 ' + (a.w * largeur) + ' ' + (a.h * hauteur)" preserveAspectRatio="none">
@if (a.type === 'rectangle') {
<!-- Trait rentré dans la boîte (box-sizing: border-box) pour
que le rendu colle exactement au curseur / à la sélection. -->
<rect [attr.x]="rectInset(a).x" [attr.y]="rectInset(a).y"
[attr.width]="rectInset(a).width" [attr.height]="rectInset(a).height"
[attr.stroke]="forme(a).couleur" [attr.stroke-width]="strokeW(a)"
[attr.fill]="forme(a).remplissage || 'none'" />
} @else if (a.type === 'cercle') {
<ellipse [attr.cx]="ellipseInset(a).cx" [attr.cy]="ellipseInset(a).cy"
[attr.rx]="ellipseInset(a).rx" [attr.ry]="ellipseInset(a).ry"
[attr.stroke]="forme(a).couleur" [attr.stroke-width]="strokeW(a)"
[attr.fill]="forme(a).remplissage || 'none'" />
} @else if (a.type === 'ligne') {
<line x1="0" y1="0" [attr.x2]="a.w*largeur" [attr.y2]="a.h*hauteur"
[attr.stroke]="forme(a).couleur" [attr.stroke-width]="forme(a).epaisseur * zoom" />
} @else if (a.type === 'fleche') {
<line x1="0" y1="0" [attr.x2]="a.w*largeur" [attr.y2]="a.h*hauteur"
[attr.stroke]="forme(a).couleur" [attr.stroke-width]="forme(a).epaisseur * zoom"
[attr.marker-end]="'url(#fleche-' + a.id + ')'" />
<defs>
<marker [attr.id]="'fleche-' + a.id" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
<path d="M0,0 L8,3 L0,6 Z" [attr.fill]="forme(a).couleur" />
</marker>
</defs>
} @else if (a.type === 'croix') {
<line x1="0" y1="0" [attr.x2]="a.w*largeur" [attr.y2]="a.h*hauteur"
[attr.stroke]="forme(a).couleur" [attr.stroke-width]="forme(a).epaisseur * zoom" />
<line [attr.x1]="a.w*largeur" y1="0" x2="0" [attr.y2]="a.h*hauteur"
[attr.stroke]="forme(a).couleur" [attr.stroke-width]="forme(a).epaisseur * zoom" />
}
</svg>
}
}
@if (a.id === selectionId) {
@if (a.type === 'texte') {
<div class="barre-texte" (mousedown)="$event.stopPropagation()">
<button class="mini" (click)="changerTaille(a, -2)" title="Réduire">A</button>
<input
class="champ-taille"
type="number"
min="6"
max="300"
[value]="texte(a).taillePolice"
(input)="majTaille(a, $any($event.target).value)"
/>
<button class="mini" (click)="changerTaille(a, 2)" title="Agrandir">A+</button>
</div>
}
<button class="poignee-suppr" (mousedown)="$event.stopPropagation()" (click)="supprimer(a)">×</button>
<span class="poignee-resize" (mousedown)="debutRedim($event, a)"></span>
}
</div>
}
</div>
</div>
`,
styles: [
`
.page {
position: relative;
margin: 0 auto 24px;
background: #fff;
box-shadow: var(--ombre);
}
.rendu {
display: block;
}
.couche {
position: absolute;
inset: 0;
}
.annotation {
position: absolute;
box-sizing: border-box;
cursor: move;
}
.annotation.selectionnee {
outline: 1px dashed var(--couleur-primaire);
}
.contenu-texte {
width: 100%;
height: 100%;
outline: none;
white-space: pre-wrap;
overflow: hidden;
cursor: move;
}
.contenu-texte.edition {
cursor: text;
}
.contenu-image {
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
}
.contenu-forme {
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.contenu-lien {
width: 100%;
height: 100%;
font-size: 12px;
color: var(--couleur-primaire);
border: 1px solid var(--couleur-primaire);
background: rgba(59, 108, 255, 0.06);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: 2px 4px;
}
.barre-texte {
position: absolute;
top: -34px;
left: 0;
display: flex;
align-items: center;
gap: 4px;
padding: 3px 5px;
background: #fff;
border: 1px solid var(--couleur-bordure, #d0d0d0);
border-radius: 6px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
cursor: default;
white-space: nowrap;
}
.barre-texte .mini {
border: none;
background: #f0f0f0;
border-radius: 4px;
width: 26px;
height: 24px;
cursor: pointer;
font-size: 12px;
}
.barre-texte .mini:hover {
background: #e2e2e2;
}
.barre-texte .champ-taille {
width: 48px;
height: 24px;
text-align: center;
border: 1px solid var(--couleur-bordure, #d0d0d0);
border-radius: 4px;
font-size: 13px;
}
.poignee-suppr {
position: absolute;
top: -12px;
right: -12px;
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: var(--couleur-danger);
color: #fff;
cursor: pointer;
line-height: 1;
font-size: 15px;
}
.poignee-resize {
position: absolute;
bottom: -6px;
right: -6px;
width: 12px;
height: 12px;
background: var(--couleur-primaire);
border: 2px solid #fff;
border-radius: 2px;
cursor: nwse-resize;
}
`,
],
})
export class PageLayerComponent implements AfterViewInit, OnChanges {
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
@ViewChild('conteneur') conteneurRef!: ElementRef<HTMLDivElement>;
/** Document pdf.js partagé. */
@Input({ required: true }) pdf!: PDFDocumentProxy;
/** Index de page 0-based. */
@Input({ required: true }) pageIndex!: number;
/** Niveau de zoom (1 = 100%). */
@Input({ required: true }) zoom = 1;
/** Outil actif sélectionné dans la barre d'outils. */
@Input({ required: true }) outil: Outil = 'selection';
/** Toutes les annotations du document. */
@Input({ required: true }) annotations: Annotation[] = [];
/** Couleur courante de la barre d'outils. */
@Input() couleur = '#000000';
/** Data URL d'une image/signature à poser au prochain clic (image/signature). */
@Input() chargeUtile: string | null = null;
@Output() annotationsChange = new EventEmitter<Annotation[]>();
/** Demande au parent de réinitialiser l'outil (revenir en sélection) après pose. */
@Output() outilConsomme = new EventEmitter<void>();
private readonly pdfService = inject(PdfService);
largeur = 0;
hauteur = 0;
dbg = 'v14 (en attente)';
/** Page pdf.js et ses dimensions de base (échelle 1), mises en cache. */
private pageProxy?: PDFPageProxy;
private baseLargeur = 0;
private baseHauteur = 0;
/** Rendu pdf.js en cours (pour pouvoir l'annuler au changement de zoom). */
private renderTask?: ReturnType<PDFPageProxy['render']>;
selectionId: string | null = null;
/** Annotation texte actuellement en cours d'édition (double-clic). */
editionId: string | null = null;
// État d'interaction en cours.
private mode: 'aucun' | 'creation' | 'deplacement' | 'redim' = 'aucun';
private brouillon: Annotation | null = null;
private depart = { x: 0, y: 0, ax: 0, ay: 0, aw: 0, ah: 0 };
// --- Helpers de typage pour le template --------------------------------
texte = (a: Annotation) => a as AnnotationTexte;
image = (a: Annotation) => a as AnnotationImage;
forme = (a: Annotation) => a as AnnotationForme;
lien = (a: Annotation) => a as AnnotationLien;
/** Épaisseur du trait en pixels écran (points * zoom). */
strokeW = (a: Annotation) => this.forme(a).epaisseur * this.zoom;
/**
* Géométrie d'une forme remplie (rectangle/cercle) en RENTRANT le trait à
* l'intérieur de la boîte (façon box-sizing: border-box). Sans cet inset, la
* moitié de l'épaisseur déborderait de la boîte (overflow:visible), ce qui
* décale visiblement l'anchor sur une petite forme.
*/
rectInset = (a: Annotation) => {
const sw = this.strokeW(a);
return {
x: sw / 2,
y: sw / 2,
width: Math.max(0, a.w * this.largeur - sw),
height: Math.max(0, a.h * this.hauteur - sw),
};
};
ellipseInset = (a: Annotation) => {
const sw = this.strokeW(a);
const rx = (a.w * this.largeur) / 2;
const ry = (a.h * this.hauteur) / 2;
return {
cx: rx,
cy: ry,
rx: Math.max(0, rx - sw / 2),
ry: Math.max(0, ry - sw / 2),
};
};
annotationsPage(): Annotation[] {
return this.annotations.filter((a) => a.page === this.pageIndex);
}
async ngAfterViewInit(): Promise<void> {
const r = await this.pdfService.chargerPage(this.pdf, this.pageIndex + 1);
this.pageProxy = r.page;
this.baseLargeur = r.baseLargeur;
this.baseHauteur = r.baseHauteur;
this.redimensionner();
await this.repeindre();
}
async ngOnChanges(changes: SimpleChanges): Promise<void> {
// Au changement de zoom : on redimensionne la page de façon SYNCHRONE (donc
// immédiate), puis on repeint. La page est toujours à la bonne taille quand
// l'utilisateur dessine, même si le rendu pixel est lent (PDF lourd).
if (changes['zoom'] && this.pageProxy) {
this.redimensionner();
await this.repeindre();
}
}
/** Dimensionnement synchrone du canvas + de la couche d'annotations. */
private redimensionner(): void {
const dims = this.pdfService.dimensionner(
this.canvasRef.nativeElement,
this.baseLargeur,
this.baseHauteur,
this.zoom,
);
this.largeur = dims.largeur;
this.hauteur = dims.hauteur;
}
/** DEBUG TEMP : mesure repère vert (canvas) vs repère bleu (DOM). */
private mesurerDebug(): void {
if (this.pageIndex !== 0) {
return;
}
const canvas = this.canvasRef.nativeElement;
const cr = canvas.getBoundingClientRect();
const blue = this.conteneurRef.nativeElement.querySelector(
'.couche > div',
) as HTMLElement | null;
const br = blue?.getBoundingClientRect();
const ctx = canvas.getContext('2d');
let gx = -1,
gy = -1;
if (ctx) {
const w = canvas.width,
h = canvas.height;
const data = ctx.getImageData(0, 0, w, h).data;
for (let y = 0; y < h && gy < 0; y += 1) {
for (let x = 0; x < w; x += 1) {
const i = (y * w + x) * 4;
if (data[i] < 80 && data[i + 1] > 150 && data[i + 2] < 80) {
gx = x;
gy = y;
break;
}
}
}
}
const ech = cr.width / canvas.width; // bitmap -> CSS px
const gL = cr.left + gx * ech;
const gT = cr.top + gy * ech;
this.dbg =
`v16 dpr=${window.devicePixelRatio} zoom=${this.zoom} bmp=${canvas.width}x${canvas.height}\n` +
`canvas L=${cr.left.toFixed(1)} T=${cr.top.toFixed(1)} W=${cr.width.toFixed(1)}\n` +
`VERT bmp x=${gx} y=${gy} -> ecran L=${gL.toFixed(1)} T=${gT.toFixed(1)}\n` +
`BLEU ecran L=${br?.left.toFixed(1)} T=${br?.top.toFixed(1)}\n` +
`=> ECART vert-bleu X=${br ? (gL - br.left).toFixed(1) : '?'} Y=${br ? (gT - br.top).toFixed(1) : '?'}`;
}
/** Rendu pixel (asynchrone) de la page dans le canvas déjà dimensionné. */
private async repeindre(): Promise<void> {
if (!this.pageProxy) {
return;
}
// Annule un éventuel rendu en cours (évite les rendus concurrents au zoom
// rapide, qui peignaient un contenu décalé par rapport au canvas final).
if (this.renderTask) {
try {
this.renderTask.cancel();
} catch {
/* déjà terminé */
}
}
const canvas = this.canvasRef.nativeElement;
this.renderTask = this.pdfService.peindre(this.pageProxy, canvas, this.zoom);
try {
await this.renderTask.promise;
} catch {
return; // rendu annulé : on laisse le rendu suivant peindre
}
// DEBUG TEMP : repère VERT plein (contenu canvas) à la fraction 30%-70%.
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.strokeStyle = 'rgb(0,200,0)';
ctx.lineWidth = 3;
ctx.strokeRect(
0.3 * canvas.width,
0.3 * canvas.height,
0.4 * canvas.width,
0.4 * canvas.height,
);
}
this.mesurerDebug();
}
// --- Création d'une annotation par cliquer-glisser ---------------------
debutInteraction(ev: MouseEvent): void {
// Clic sur le fond : désélection + sortie d'édition + éventuelle création.
this.selectionId = null;
this.editionId = null;
if (this.outil === 'selection') {
return;
}
// On convertit la position souris en fraction [0..1] à partir de la taille
// RÉELLE du conteneur (getBoundingClientRect), et non de this.largeur/
// this.hauteur : ces variables sont mises à jour par rendre() qui est async,
// elles peuvent donc être périmées pendant un zoom et provoquer un décalage
// proportionnel au zoom. rect.width/rect.height reflètent toujours l'état
// affiché à l'écran.
const rect = this.conteneurRef.nativeElement.getBoundingClientRect();
const x = (ev.clientX - rect.left) / rect.width;
const y = (ev.clientY - rect.top) / rect.height;
// Outils « ponctuels » : pose immédiate puis retour en sélection.
if (this.outil === 'texte') {
const t = this.creerTexte(x, y);
this.poser(t);
this.editionId = t.id; // édition immédiate du nouveau texte
return;
}
if (this.outil === 'image' || this.outil === 'signature') {
if (this.chargeUtile) {
this.creerImage(x, y, this.outil, this.chargeUtile).then((a) =>
this.poser(a),
);
}
return;
}
if (this.outil === 'lien') {
const url = prompt('URL du lien (https://…)');
if (url) {
this.poser(this.creerLien(x, y, url));
}
return;
}
// Formes : création par glissement.
this.mode = 'creation';
this.brouillon = this.creerForme(this.outil, x, y, this.couleur);
this.depart = { x, y, ax: x, ay: y, aw: 0, ah: 0 };
this.poser(this.brouillon, false);
this.attacherSuivi();
}
debutDeplacement(ev: MouseEvent, a: Annotation): void {
ev.stopPropagation();
this.selectionId = a.id;
if (this.outil !== 'selection') {
return;
}
this.mode = 'deplacement';
this.brouillon = a;
const rect = this.conteneurRef.nativeElement.getBoundingClientRect();
this.depart = {
x: (ev.clientX - rect.left) / rect.width,
y: (ev.clientY - rect.top) / rect.height,
ax: a.x,
ay: a.y,
aw: a.w,
ah: a.h,
};
this.attacherSuivi();
}
debutRedim(ev: MouseEvent, a: Annotation): void {
ev.stopPropagation();
this.mode = 'redim';
this.brouillon = a;
const rect = this.conteneurRef.nativeElement.getBoundingClientRect();
this.depart = {
x: (ev.clientX - rect.left) / rect.width,
y: (ev.clientY - rect.top) / rect.height,
ax: a.x,
ay: a.y,
aw: a.w,
ah: a.h,
};
this.attacherSuivi();
}
private attacherSuivi(): void {
const onMove = (ev: MouseEvent) => this.surMouvement(ev);
const onUp = () => {
this.mode = 'aucun';
this.brouillon = null;
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
this.emettre();
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
private surMouvement(ev: MouseEvent): void {
if (this.mode === 'aucun' || !this.brouillon) {
return;
}
const rect = this.conteneurRef.nativeElement.getBoundingClientRect();
const x = (ev.clientX - rect.left) / rect.width;
const y = (ev.clientY - rect.top) / rect.height;
const dx = x - this.depart.x;
const dy = y - this.depart.y;
if (this.mode === 'creation') {
this.brouillon.x = Math.min(this.depart.ax, x);
this.brouillon.y = Math.min(this.depart.ay, y);
this.brouillon.w = Math.abs(x - this.depart.ax);
this.brouillon.h = Math.abs(y - this.depart.ay);
} else if (this.mode === 'deplacement') {
this.brouillon.x = this.depart.ax + dx;
this.brouillon.y = this.depart.ay + dy;
} else if (this.mode === 'redim') {
this.brouillon.w = Math.max(0.01, this.depart.aw + dx);
this.brouillon.h = Math.max(0.01, this.depart.ah + dy);
}
// Mutation in place : on déclenche un nouveau tableau pour la détection.
this.annotations = [...this.annotations];
}
// --- Mise à jour / suppression -----------------------------------------
/** Double-clic sur un texte : passe en mode édition et place le curseur. */
entrerEdition(a: Annotation, ev: MouseEvent): void {
ev.stopPropagation();
this.selectionId = a.id;
this.editionId = a.id;
const el = ev.target as HTMLElement;
// Le focus doit attendre que l'attribut contenteditable soit appliqué.
setTimeout(() => {
el.focus();
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false); // curseur en fin de texte
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
});
}
/** Fin d'édition (blur) : on enregistre le texte saisi. */
finEdition(a: Annotation, ev: Event): void {
const el = ev.target as HTMLElement;
(a as AnnotationTexte).texte = el.innerText;
this.editionId = null;
this.emettre();
}
/** Ajuste la taille de police d'un texte par incrément (boutons A / A+). */
changerTaille(a: Annotation, delta: number): void {
this.appliquerTaille(a, (a as AnnotationTexte).taillePolice + delta);
}
/** Définit la taille de police d'un texte depuis le champ numérique. */
majTaille(a: Annotation, valeur: string): void {
const n = parseInt(valeur, 10);
if (!Number.isNaN(n)) {
this.appliquerTaille(a, n);
}
}
private appliquerTaille(a: Annotation, taille: number): void {
(a as AnnotationTexte).taillePolice = Math.max(6, Math.min(300, taille));
this.annotations = [...this.annotations];
this.emettre();
}
supprimer(a: Annotation): void {
this.annotations = this.annotations.filter((x) => x.id !== a.id);
this.selectionId = null;
this.emettre();
}
// --- Fabriques d'annotations -------------------------------------------
private poser(a: Annotation, consomme = true): void {
this.annotations = [...this.annotations, a];
this.selectionId = a.id;
if (consomme) {
this.emettre();
this.outilConsomme.emit();
}
}
private emettre(): void {
this.annotationsChange.emit([...this.annotations]);
}
private creerTexte(x: number, y: number): AnnotationTexte {
return {
id: nouvelId(),
type: 'texte',
page: this.pageIndex,
x,
y,
w: 0.25,
h: 0.05,
texte: 'Votre texte',
taillePolice: 16,
couleur: this.couleur,
};
}
private async creerImage(
x: number,
y: number,
type: 'image' | 'signature',
dataUrl: string,
): Promise<AnnotationImage> {
const w = type === 'signature' ? 0.3 : 0.3;
let h = type === 'signature' ? 0.1 : 0.2;
try {
const dim = await this.dimensionsImage(dataUrl);
// Préserve le ratio visuel : (w*largeur)/(h*hauteur) = dim.w/dim.h
h = (w * this.largeur * dim.h) / (dim.w * this.hauteur);
} catch {
// En cas d'échec de lecture, on garde les dimensions par défaut.
}
return {
id: nouvelId(),
type,
page: this.pageIndex,
x,
y,
w,
h,
dataUrl,
};
}
/** Lit les dimensions naturelles (px) d'une image encodée en data URL. */
private dimensionsImage(dataUrl: string): Promise<{ w: number; h: number }> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve({ w: img.naturalWidth, h: img.naturalHeight });
img.onerror = reject;
img.src = dataUrl;
});
}
private creerLien(x: number, y: number, url: string): AnnotationLien {
return {
id: nouvelId(),
type: 'lien',
page: this.pageIndex,
x,
y,
w: 0.2,
h: 0.04,
url,
};
}
private creerForme(
type: Outil,
x: number,
y: number,
couleur: string,
): AnnotationForme {
return {
id: nouvelId(),
type: type as AnnotationForme['type'],
page: this.pageIndex,
x,
y,
w: 0,
h: 0,
couleur,
epaisseur: 2,
remplissage: null,
};
}
}

View File

@ -0,0 +1,353 @@
import { Injectable } from '@angular/core';
import {
PDFDocument,
PDFName,
PDFArray,
rgb,
StandardFonts,
} from 'pdf-lib';
import * as pdfjs from 'pdfjs-dist';
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
import {
Annotation,
AnnotationForme,
AnnotationImage,
AnnotationLien,
AnnotationTexte,
} from './annotation.model';
// Le worker pdf.js est copié dans /assets au build (cf. angular.json).
pdfjs.GlobalWorkerOptions.workerSrc = 'assets/pdf.worker.min.mjs';
/** Convertit un #rrggbb en composante rgb() de pdf-lib (0..1). */
function couleurVersRgb(hex: string) {
const h = hex.replace('#', '');
const r = parseInt(h.substring(0, 2), 16) / 255;
const g = parseInt(h.substring(2, 4), 16) / 255;
const b = parseInt(h.substring(4, 6), 16) / 255;
return rgb(r || 0, g || 0, b || 0);
}
/**
* Service de manipulation des PDF :
* - rendu page par page avec pdf.js (pour l'affichage interactif) ;
* - aplatissement des annotations dans le PDF avec pdf-lib (pour l'export).
*/
@Injectable({ providedIn: 'root' })
export class PdfService {
/** Charge un document pdf.js depuis un ArrayBuffer. */
async charger(data: ArrayBuffer): Promise<PDFDocumentProxy> {
// On clone le buffer car pdf.js le « détache » (transférable).
const copie = data.slice(0);
return pdfjs.getDocument({ data: copie }).promise;
}
/** Charge une page pdf.js et retourne ses dimensions de base (échelle 1). */
async chargerPage(
pdf: PDFDocumentProxy,
numeroPage: number,
): Promise<{ page: PDFPageProxy; baseLargeur: number; baseHauteur: number }> {
const page = await pdf.getPage(numeroPage);
const vp = page.getViewport({ scale: 1 });
return { page, baseLargeur: vp.width, baseHauteur: vp.height };
}
/**
* Dimensionne le canvas de façon SYNCHRONE à partir des dimensions de base et
* du zoom (aucun await), et retourne la taille d'affichage (CSS px) à utiliser
* pour la couche d'annotations. Bitmap en pixels physiques entiers, affichage
* = bitmap/ratio (1:1, sans étirement) -> alignement canvas <-> overlay exact.
*
* Étant synchrone, la page prend sa bonne taille immédiatement au changement de
* zoom : l'utilisateur ne peut pas dessiner sur une page encore à l'ancienne
* taille (ce qui décalait les annotations sur les PDF lourds à rendu lent).
*/
dimensionner(
canvas: HTMLCanvasElement,
baseLargeur: number,
baseHauteur: number,
echelle: number,
): { largeur: number; hauteur: number } {
const ratio = window.devicePixelRatio || 1;
const bitmapW = Math.round(baseLargeur * echelle * ratio);
const bitmapH = Math.round(baseHauteur * echelle * ratio);
canvas.width = bitmapW;
canvas.height = bitmapH;
const cssW = bitmapW / ratio;
const cssH = bitmapH / ratio;
canvas.style.width = `${cssW}px`;
canvas.style.height = `${cssH}px`;
return { largeur: cssW, hauteur: cssH };
}
/**
* Démarre le rendu (rasterisation) de la page dans le canvas déjà dimensionné
* et retourne la RenderTask pdf.js (qui expose `.promise` et `.cancel()`).
* L'appelant doit annuler la tâche précédente avant d'en démarrer une nouvelle,
* sinon des rendus concurrents (zoom rapide) se résolvent dans le désordre et
* peignent un contenu incohérent avec la taille finale du canvas.
*/
peindre(
page: PDFPageProxy,
canvas: HTMLCanvasElement,
echelle: number,
): ReturnType<PDFPageProxy['render']> {
const contexte = canvas.getContext('2d');
if (!contexte) {
throw new Error('Contexte 2D indisponible');
}
const ratio = window.devicePixelRatio || 1;
const viewport = page.getViewport({ scale: echelle });
// Le bitmap est en pixels ENTIERS (cf. dimensionner) alors que viewport est
// fractionnaire : on étire très légèrement le rendu pour qu'il remplisse
// EXACTEMENT le canvas -> alignement pixel-parfait contenu/couche.
const sx = canvas.width / (viewport.width * ratio);
const sy = canvas.height / (viewport.height * ratio);
contexte.setTransform(ratio * sx, 0, 0, ratio * sy, 0, 0);
return page.render({ canvasContext: contexte, viewport });
}
/**
* Aplatit toutes les annotations dans le PDF source et retourne le binaire
* résultant (Uint8Array), prêt au téléchargement ou à l'upload.
*
* @param sourcePdf binaire du PDF original
* @param annotations annotations à appliquer (coordonnées en fraction de page)
*/
async exporter(
sourcePdf: ArrayBuffer,
annotations: Annotation[],
): Promise<Uint8Array> {
const doc = await PDFDocument.load(sourcePdf);
const police = await doc.embedFont(StandardFonts.Helvetica);
const pages = doc.getPages();
// Pré-chargement des images embarquées (mutualisation par dataUrl).
const cacheImages = new Map<string, Awaited<ReturnType<PDFDocument['embedPng']>>>();
const embarquerImage = async (dataUrl: string) => {
if (cacheImages.has(dataUrl)) {
return cacheImages.get(dataUrl)!;
}
const octets = this.dataUrlVersOctets(dataUrl);
const img = dataUrl.startsWith('data:image/png')
? await doc.embedPng(octets)
: await doc.embedJpg(octets);
cacheImages.set(dataUrl, img);
return img;
};
for (const a of annotations) {
const page = pages[a.page];
if (!page) {
continue;
}
const { width: pw, height: ph } = page.getSize();
// Conversion fraction -> points PDF. En PDF l'origine est en bas-gauche,
// alors que nos annotations sont en haut-gauche : on inverse l'axe Y.
const x = a.x * pw;
const largeur = a.w * pw;
const hauteur = a.h * ph;
const yHaut = ph - a.y * ph; // bord supérieur de l'annotation
const yBas = yHaut - hauteur;
switch (a.type) {
case 'texte':
this.dessinerTexte(page, a, police, x, yHaut);
break;
case 'image':
case 'signature': {
const img = await embarquerImage((a as AnnotationImage).dataUrl);
page.drawImage(img, { x, y: yBas, width: largeur, height: hauteur });
break;
}
case 'rectangle':
case 'cercle':
case 'ligne':
case 'fleche':
case 'croix':
this.dessinerForme(page, a, x, yBas, yHaut, largeur, hauteur);
break;
case 'lien':
this.dessinerLien(doc, page, a, x, yBas, largeur, hauteur);
break;
}
}
return doc.save();
}
// --- Rendu des différents types pour l'export --------------------------
private dessinerTexte(
page: ReturnType<PDFDocument['getPages']>[number],
a: AnnotationTexte,
police: Awaited<ReturnType<PDFDocument['embedFont']>>,
x: number,
yHaut: number,
): void {
// taillePolice est exprimée en px à 100% : on l'exprime en points relatifs
// à la hauteur de page (le rendu pdf.js à scale=1 ~ 1px = 1pt).
const taille = a.taillePolice;
const lignes = a.texte.split('\n');
let y = yHaut - taille; // ligne de base de la première ligne
for (const ligne of lignes) {
page.drawText(ligne, {
x,
y,
size: taille,
font: police,
color: couleurVersRgb(a.couleur),
});
y -= taille * 1.2;
}
}
private dessinerForme(
page: ReturnType<PDFDocument['getPages']>[number],
a: AnnotationForme,
x: number,
yBas: number,
yHaut: number,
largeur: number,
hauteur: number,
): void {
const trait = couleurVersRgb(a.couleur);
const remplissage =
a.remplissage && a.remplissage !== 'none'
? couleurVersRgb(a.remplissage)
: undefined;
switch (a.type) {
case 'rectangle':
// Trait rentré dans la boîte (border-box) pour rester WYSIWYG avec
// l'écran. À l'échelle 1, a.epaisseur est en points (pas de zoom).
page.drawRectangle({
x: x + a.epaisseur / 2,
y: yBas + a.epaisseur / 2,
width: Math.max(0, largeur - a.epaisseur),
height: Math.max(0, hauteur - a.epaisseur),
borderColor: trait,
borderWidth: a.epaisseur,
color: remplissage,
});
break;
case 'cercle':
page.drawEllipse({
x: x + largeur / 2,
y: yBas + hauteur / 2,
xScale: Math.max(0, largeur / 2 - a.epaisseur / 2),
yScale: Math.max(0, hauteur / 2 - a.epaisseur / 2),
borderColor: trait,
borderWidth: a.epaisseur,
color: remplissage,
});
break;
case 'ligne':
page.drawLine({
start: { x, y: yHaut },
end: { x: x + largeur, y: yBas },
thickness: a.epaisseur,
color: trait,
});
break;
case 'croix':
// Deux diagonales formant un X dans la boîte englobante.
page.drawLine({
start: { x, y: yHaut },
end: { x: x + largeur, y: yBas },
thickness: a.epaisseur,
color: trait,
});
page.drawLine({
start: { x, y: yBas },
end: { x: x + largeur, y: yHaut },
thickness: a.epaisseur,
color: trait,
});
break;
case 'fleche': {
const finX = x + largeur;
const finY = yBas;
page.drawLine({
start: { x, y: yHaut },
end: { x: finX, y: finY },
thickness: a.epaisseur,
color: trait,
});
// Pointe de flèche : deux petits segments.
const angle = Math.atan2(finY - yHaut, finX - x);
const taillePointe = 10 + a.epaisseur * 2;
for (const delta of [Math.PI - 0.4, Math.PI + 0.4]) {
page.drawLine({
start: { x: finX, y: finY },
end: {
x: finX + taillePointe * Math.cos(angle + delta),
y: finY + taillePointe * Math.sin(angle + delta),
},
thickness: a.epaisseur,
color: trait,
});
}
break;
}
}
}
private dessinerLien(
doc: PDFDocument,
page: ReturnType<PDFDocument['getPages']>[number],
a: AnnotationLien,
x: number,
yBas: number,
largeur: number,
hauteur: number,
): void {
// Rectangle visible (encadré bleu léger) pour matérialiser la zone cliquable.
page.drawRectangle({
x,
y: yBas,
width: largeur,
height: hauteur,
borderColor: couleurVersRgb('#3b6cff'),
borderWidth: 1,
});
// Annotation de lien interactive (API bas niveau de pdf-lib).
// ctx.obj convertit automatiquement une chaîne JS en PDFString, ce qui
// produit une URI correctement encodée.
const ctx = doc.context;
const annot = ctx.obj({
Type: 'Annot',
Subtype: 'Link',
Rect: [x, yBas, x + largeur, yBas + hauteur],
Border: [0, 0, 0],
A: ctx.obj({
Type: 'Action',
S: 'URI',
URI: a.url,
}),
});
const ref = ctx.register(annot);
// Récupère le tableau Annots existant de la page ou en crée un.
const cleAnnots = PDFName.of('Annots');
let annots = page.node.lookupMaybe(cleAnnots, PDFArray);
if (!annots) {
annots = ctx.obj([]) as PDFArray;
page.node.set(cleAnnots, annots);
}
annots.push(ref);
}
/** Décode une data URL base64 en Uint8Array. */
private dataUrlVersOctets(dataUrl: string): Uint8Array {
const base64 = dataUrl.split(',')[1] ?? '';
const binaire = atob(base64);
const octets = new Uint8Array(binaire.length);
for (let i = 0; i < binaire.length; i++) {
octets[i] = binaire.charCodeAt(i);
}
return octets;
}
}

View File

@ -0,0 +1,297 @@
import {
Component,
ElementRef,
EventEmitter,
OnDestroy,
Output,
ViewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import SignaturePad from 'signature_pad';
/** Police manuscrite proposée pour la signature dactylographiée. */
interface PoliceSignature {
nom: string;
/** Famille seule (avec guillemets), ex. "'Dancing Script'" — pour la Font Loading API. */
famille: string;
/** Pile complète avec repli, ex. "'Dancing Script', cursive" — pour le rendu CSS/canvas. */
css: string;
}
/**
* Boîte de dialogue de signature, avec deux modes :
* - « Dessiner » : tracé à main levée (librairie signature_pad) ;
* - « Écrire » : saisie d'un texte rendu dans une police manuscrite,
* converti en image PNG transparente.
*
* Dans les deux cas, la signature est émise en data URL PNG via `valide`
* (l'éditeur la pose ensuite comme une image), ou `annule` à la fermeture.
*/
@Component({
selector: 'app-signature-dialog',
standalone: true,
imports: [FormsModule],
template: `
<div class="overlay" (click)="annuler()">
<div class="modale" (click)="$event.stopPropagation()">
<div class="onglets">
<button
class="onglet"
[class.actif]="mode === 'dessin'"
(click)="mode = 'dessin'"
>
✍ Dessiner
</button>
<button
class="onglet"
[class.actif]="mode === 'texte'"
(click)="mode = 'texte'"
>
⌨ Écrire
</button>
</div>
<!-- Mode dessin (le canvas reste dans le DOM pour conserver le tracé) -->
<div class="panneau" [style.display]="mode === 'dessin' ? 'block' : 'none'">
<div class="zone-signature">
<canvas #canvas width="500" height="200"></canvas>
</div>
<div class="actions">
<button class="btn" (click)="effacer()">Effacer</button>
</div>
</div>
<!-- Mode texte stylisé -->
<div class="panneau" [style.display]="mode === 'texte' ? 'block' : 'none'">
<input
class="champ-texte"
type="text"
placeholder="Tapez votre nom…"
[(ngModel)]="texteSignature"
name="signature"
/>
<div class="polices">
@for (p of polices; track p.nom) {
<button
class="choix-police"
[class.actif]="police === p"
[style.fontFamily]="p.css"
(click)="police = p"
>
{{ texteSignature || 'Signature' }}
</button>
}
</div>
<div class="apercu">
<span [style.fontFamily]="police.css">{{ texteSignature || 'Aperçu de la signature' }}</span>
</div>
</div>
<div class="actions pied">
<span class="espace"></span>
<button class="btn" (click)="annuler()">Annuler</button>
<button class="btn btn-primaire" (click)="valider()">
Insérer la signature
</button>
</div>
</div>
</div>
`,
styles: [
`
.overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modale {
background: #fff;
border-radius: 10px;
padding: 22px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
width: 540px;
max-width: 92vw;
}
.onglets {
display: flex;
gap: 6px;
margin-bottom: 16px;
}
.onglet {
flex: 1;
padding: 9px 12px;
border: 1px solid var(--couleur-bordure, #d0d0d0);
background: #f7f7f7;
border-radius: 8px;
cursor: pointer;
font: inherit;
}
.onglet.actif {
background: var(--couleur-primaire);
color: #fff;
border-color: var(--couleur-primaire);
}
.zone-signature {
border: 2px dashed var(--couleur-bordure);
border-radius: 8px;
background: #fff;
}
canvas {
display: block;
touch-action: none;
cursor: crosshair;
width: 100%;
}
.champ-texte {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
font-size: 16px;
border: 1px solid var(--couleur-bordure, #d0d0d0);
border-radius: 8px;
}
.polices {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 12px;
}
.choix-police {
padding: 10px;
border: 1px solid var(--couleur-bordure, #d0d0d0);
border-radius: 8px;
background: #fff;
cursor: pointer;
font-size: 26px;
color: #0a2540;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.choix-police.actif {
border-color: var(--couleur-primaire);
box-shadow: 0 0 0 2px var(--couleur-primaire) inset;
}
.apercu {
margin-top: 14px;
min-height: 70px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--couleur-bordure, #eee);
border-radius: 8px;
background: #fafafa;
font-size: 44px;
color: #0a2540;
overflow: hidden;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 14px;
}
.actions.pied {
margin-top: 18px;
}
.espace {
flex: 1;
}
`,
],
})
export class SignatureDialogComponent implements OnDestroy {
@Output() valide = new EventEmitter<string>();
@Output() annule = new EventEmitter<void>();
mode: 'dessin' | 'texte' = 'dessin';
// --- Mode texte ---
texteSignature = '';
readonly polices: PoliceSignature[] = [
{ nom: 'Dancing Script', famille: "'Dancing Script'", css: "'Dancing Script', cursive" },
{ nom: 'Great Vibes', famille: "'Great Vibes'", css: "'Great Vibes', cursive" },
{ nom: 'Sacramento', famille: "'Sacramento'", css: "'Sacramento', cursive" },
{ nom: 'Satisfy', famille: "'Satisfy'", css: "'Satisfy', cursive" },
{ nom: 'Caveat', famille: "'Caveat'", css: "'Caveat', cursive" },
{ nom: 'Allura', famille: "'Allura'", css: "'Allura', cursive" },
];
police: PoliceSignature = this.polices[0];
// --- Mode dessin ---
private pad?: SignaturePad;
/**
* ViewChild via setter : le canvas n'apparaît qu'en mode dessin (display),
* mais reste dans le DOM, donc le setter est appelé une fois à l'init.
*/
@ViewChild('canvas')
set canvasRef(ref: ElementRef<HTMLCanvasElement> | undefined) {
if (ref && !this.pad) {
this.pad = new SignaturePad(ref.nativeElement, {
penColor: '#0a2540',
backgroundColor: 'rgba(0,0,0,0)', // fond transparent
});
}
}
ngOnDestroy(): void {
this.pad?.off();
}
effacer(): void {
this.pad?.clear();
}
annuler(): void {
this.annule.emit();
}
async valider(): Promise<void> {
if (this.mode === 'dessin') {
if (!this.pad || this.pad.isEmpty()) {
return;
}
this.valide.emit(this.pad.toDataURL('image/png'));
} else {
const dataUrl = await this.genererTexteSignature();
if (dataUrl) {
this.valide.emit(dataUrl);
}
}
}
/** Rend le texte saisi dans la police choisie sur un canvas PNG transparent. */
private async genererTexteSignature(): Promise<string | null> {
const texte = this.texteSignature.trim();
if (!texte) {
return null;
}
const taille = 96;
const marge = 40;
// Le canvas n'utilise une police que si elle est déjà chargée par le navigateur :
// on l'attend explicitement via la Font Loading API avant de mesurer/dessiner.
await document.fonts.load(`${taille}px ${this.police.famille}`);
const mesure = document.createElement('canvas').getContext('2d')!;
const fontCss = `${taille}px ${this.police.css}`;
mesure.font = fontCss;
const largeur = Math.ceil(mesure.measureText(texte).width + marge * 2);
const hauteur = Math.ceil(taille * 1.6);
const canvas = document.createElement('canvas');
canvas.width = largeur;
canvas.height = hauteur;
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, largeur, hauteur);
ctx.font = fontCss;
ctx.fillStyle = '#0a2540';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(texte, largeur / 2, hauteur / 2);
return canvas.toDataURL('image/png');
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

14
frontend/src/index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<title>PdfEditor</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Éditeur de PDF web auto-hébergé" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

7
frontend/src/main.ts Normal file
View File

@ -0,0 +1,7 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err),
);

153
frontend/src/styles.scss Normal file
View File

@ -0,0 +1,153 @@
/* Styles globaux de PdfEditor */
/* Polices manuscrites (licence OFL) pour la signature dactylographiée.
esbuild empaquette automatiquement les woff2 référencés ici. */
@font-face {
font-family: 'Dancing Script';
src: url('./assets/fonts/dancing-script.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Great Vibes';
src: url('./assets/fonts/great-vibes.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Sacramento';
src: url('./assets/fonts/sacramento.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Satisfy';
src: url('./assets/fonts/satisfy.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Caveat';
src: url('./assets/fonts/caveat.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Allura';
src: url('./assets/fonts/allura.woff2') format('woff2');
font-display: swap;
}
:root {
--couleur-primaire: #3b6cff;
--couleur-primaire-sombre: #2d54cc;
--couleur-danger: #e0413b;
--couleur-fond: #f4f6fb;
--couleur-surface: #ffffff;
--couleur-bordure: #d9dee8;
--couleur-texte: #1d2433;
--couleur-texte-doux: #6b7280;
--rayon: 8px;
--ombre: 0 2px 8px rgba(20, 30, 60, 0.08);
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
}
body {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
background: var(--couleur-fond);
color: var(--couleur-texte);
-webkit-font-smoothing: antialiased;
}
a {
color: var(--couleur-primaire);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button {
font-family: inherit;
}
/* Boutons utilitaires réutilisés dans toute l'app */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 16px;
border: 1px solid var(--couleur-bordure);
border-radius: var(--rayon);
background: var(--couleur-surface);
color: var(--couleur-texte);
font-size: 14px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.btn:hover:not(:disabled) {
background: #eef2fb;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primaire {
background: var(--couleur-primaire);
border-color: var(--couleur-primaire);
color: #fff;
}
.btn-primaire:hover:not(:disabled) {
background: var(--couleur-primaire-sombre);
}
.btn-danger {
color: var(--couleur-danger);
border-color: #f0c1bf;
}
.btn-danger:hover:not(:disabled) {
background: #fdeceb;
}
.champ {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 14px;
}
.champ label {
font-size: 13px;
font-weight: 600;
color: var(--couleur-texte-doux);
}
.champ input,
.champ select {
padding: 10px 12px;
border: 1px solid var(--couleur-bordure);
border-radius: var(--rayon);
font-size: 14px;
background: var(--couleur-surface);
}
.champ input:focus {
outline: 2px solid var(--couleur-primaire);
outline-offset: -1px;
}
.message-erreur {
color: var(--couleur-danger);
font-size: 13px;
margin: 8px 0;
}

View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}

25
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": ["jasmine"]
},
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}