feat: v1
This commit is contained in:
9
frontend/.dockerignore
Normal file
9
frontend/.dockerignore
Normal file
@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.angular
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
README.md
|
||||
12
frontend/.editorconfig
Normal file
12
frontend/.editorconfig
Normal 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
17
frontend/.gitignore
vendored
Normal 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
27
frontend/Dockerfile
Normal 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
99
frontend/README.md
Normal 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
100
frontend/angular.json
Normal 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
BIN
frontend/irm-60.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
52
frontend/nginx.conf
Normal file
52
frontend/nginx.conf
Normal 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
16346
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
frontend/package.json
Normal file
42
frontend/package.json
Normal 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
8
frontend/proxy.conf.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:8081",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug"
|
||||
}
|
||||
}
|
||||
0
frontend/public/favicon.ico
Normal file
0
frontend/public/favicon.ico
Normal file
82
frontend/src/app/app.component.ts
Normal file
82
frontend/src/app/app.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
19
frontend/src/app/app.config.ts
Normal file
19
frontend/src/app/app.config.ts
Normal 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])),
|
||||
],
|
||||
};
|
||||
53
frontend/src/app/app.routes.ts
Normal file
53
frontend/src/app/app.routes.ts
Normal 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' },
|
||||
];
|
||||
53
frontend/src/app/core/auth.guard.ts
Normal file
53
frontend/src/app/core/auth.guard.ts
Normal 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);
|
||||
};
|
||||
107
frontend/src/app/core/auth.interceptor.ts
Normal file
107
frontend/src/app/core/auth.interceptor.ts
Normal 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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
123
frontend/src/app/core/auth.service.ts
Normal file
123
frontend/src/app/core/auth.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
60
frontend/src/app/core/documents.service.ts
Normal file
60
frontend/src/app/core/documents.service.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
35
frontend/src/app/core/models.ts
Normal file
35
frontend/src/app/core/models.ts
Normal 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[];
|
||||
}
|
||||
36
frontend/src/app/features/auth/auth.scss
Normal file
36
frontend/src/app/features/auth/auth.scss
Normal 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);
|
||||
}
|
||||
90
frontend/src/app/features/auth/login.component.ts
Normal file
90
frontend/src/app/features/auth/login.component.ts
Normal 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.',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
109
frontend/src/app/features/auth/register.component.ts
Normal file
109
frontend/src/app/features/auth/register.component.ts
Normal 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."),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
187
frontend/src/app/features/dashboard/dashboard.component.ts
Normal file
187
frontend/src/app/features/dashboard/dashboard.component.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
82
frontend/src/app/features/editor/annotation.model.ts
Normal file
82
frontend/src/app/features/editor/annotation.model.ts
Normal 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' },
|
||||
];
|
||||
114
frontend/src/app/features/editor/editor.component.html
Normal file
114
frontend/src/app/features/editor/editor.component.html
Normal 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>
|
||||
138
frontend/src/app/features/editor/editor.component.scss
Normal file
138
frontend/src/app/features/editor/editor.component.scss
Normal 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;
|
||||
}
|
||||
292
frontend/src/app/features/editor/editor.component.ts
Normal file
292
frontend/src/app/features/editor/editor.component.ts
Normal 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 l’image.',
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
743
frontend/src/app/features/editor/page-layer.component.ts
Normal file
743
frontend/src/app/features/editor/page-layer.component.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
353
frontend/src/app/features/editor/pdf.service.ts
Normal file
353
frontend/src/app/features/editor/pdf.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
297
frontend/src/app/features/editor/signature-dialog.component.ts
Normal file
297
frontend/src/app/features/editor/signature-dialog.component.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
BIN
frontend/src/assets/fonts/allura.woff2
Normal file
BIN
frontend/src/assets/fonts/allura.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/caveat.woff2
Normal file
BIN
frontend/src/assets/fonts/caveat.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/dancing-script.woff2
Normal file
BIN
frontend/src/assets/fonts/dancing-script.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/great-vibes.woff2
Normal file
BIN
frontend/src/assets/fonts/great-vibes.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/sacramento.woff2
Normal file
BIN
frontend/src/assets/fonts/sacramento.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/fonts/satisfy.woff2
Normal file
BIN
frontend/src/assets/fonts/satisfy.woff2
Normal file
Binary file not shown.
14
frontend/src/index.html
Normal file
14
frontend/src/index.html
Normal 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
7
frontend/src/main.ts
Normal 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
153
frontend/src/styles.scss
Normal 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;
|
||||
}
|
||||
9
frontend/tsconfig.app.json
Normal file
9
frontend/tsconfig.app.json
Normal 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
25
frontend/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
8
frontend/tsconfig.spec.json
Normal file
8
frontend/tsconfig.spec.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": ["jasmine"]
|
||||
},
|
||||
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user