canvasframework 0.5.63 → 0.5.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1068 +1,752 @@
1
1
  import Component from '../core/Component.js';
2
2
 
3
3
  /**
4
- * Lecteur PDF embarqué avec styles Material You et Cupertino.
5
- * Supporte : affichage multi-pages, zoom, navigation, miniature, recherche,
6
- * téléchargement, impression, plein-écran, rotation.
7
- *
8
- * Utilise PDF.js (CDN) pour le rendu. La lib est chargée automatiquement.
9
- *
10
- * @class
11
- * @extends Component
12
- * @property {string} platform - 'material' ou 'cupertino'
13
- * @property {string|Uint8Array|null} src - URL ou données binaires du PDF
14
- * @property {number} currentPage - Page courante (1-based)
15
- * @property {number} totalPages - Nombre total de pages
16
- * @property {number} scale - Niveau de zoom (1 = 100%)
17
- * @property {number} rotation - Rotation en degrés (0, 90, 180, 270)
18
- * @property {boolean} loading - Chargement en cours
19
- * @property {string|null} error - Message d'erreur
4
+ * Lecteur PDF dessiné sur Canvas.
5
+ * Utilise exclusivement le système d'événements du framework :
6
+ * - onPress(x, y) → appelé par checkComponentsAtPosition sur 'start' et 'end'
7
+ * - onMove(x, y) → appelé sur 'move'
8
+ * x, y sont en coordonnées monde (scrollOffset déjà soustrait par le framework).
20
9
  */
21
10
  class PDFViewer extends Component {
22
11
 
23
- /**
24
- * @param {CanvasFramework} framework
25
- * @param {Object} [options={}]
26
- * @param {string|Uint8Array} [options.src] - URL ou données binaires du PDF
27
- * @param {number} [options.initialPage=1] - Page initiale
28
- * @param {number} [options.initialScale=1.0] - Zoom initial
29
- * @param {boolean} [options.showToolbar=true] - Afficher la toolbar
30
- * @param {boolean} [options.showThumbnails=false] - Afficher le panneau miniatures
31
- * @param {boolean} [options.allowDownload=true] - Bouton de téléchargement
32
- * @param {boolean} [options.allowPrint=true] - Bouton d'impression
33
- * @param {boolean} [options.allowFullscreen=true] - Bouton plein écran
34
- * @param {boolean} [options.allowRotate=true] - Bouton rotation
35
- * @param {boolean} [options.allowSearch=true] - Bouton recherche
36
- * @param {number} [options.minScale=0.25] - Zoom minimum
37
- * @param {number} [options.maxScale=5.0] - Zoom maximum
38
- * @param {string} [options.backgroundColor] - Fond global
39
- * @param {string} [options.primaryColor] - Couleur primaire (toolbar)
40
- * @param {string} [options.pageBackground='#FFFFFF'] - Fond page
41
- * @param {Function} [options.onPageChange] - Callback(page, total)
42
- * @param {Function} [options.onScaleChange] - Callback(scale)
43
- * @param {Function} [options.onLoad] - Callback(totalPages) après chargement
44
- * @param {Function} [options.onError] - Callback(errorMessage)
45
- */
46
12
  constructor(framework, options = {}) {
47
13
  super(framework, options);
48
14
 
49
- this.platform = framework.platform;
50
- this.src = options.src || null;
51
- this.currentPage = options.initialPage || 1;
52
- this.totalPages = 0;
53
- this.scale = options.initialScale || 1.0;
54
- this.rotation = 0;
55
- this.loading = false;
56
- this.error = null;
57
- this.showToolbar = options.showToolbar !== false;
58
- this.showThumbnails = options.showThumbnails || false;
59
- this.allowDownload = options.allowDownload !== false;
60
- this.allowPrint = options.allowPrint !== false;
61
- this.allowFullscreen= options.allowFullscreen !== false;
62
- this.allowRotate = options.allowRotate !== false;
63
- this.allowSearch = options.allowSearch !== false;
64
- this.minScale = options.minScale || 0.25;
65
- this.maxScale = options.maxScale || 5.0;
66
- this.backgroundColor= options.backgroundColor || null;
67
- this.primaryColor = options.primaryColor || null;
68
- this.pageBackground = options.pageBackground || '#FFFFFF';
69
-
70
- // Callbacks
15
+ this.platform = framework.platform;
16
+ this.src = options.src || null;
17
+ this.currentPage = options.initialPage || 1;
18
+ this.totalPages = 0;
19
+ this.scale = options.initialScale || 1.0;
20
+ this.rotation = 0;
21
+ this.loading = false;
22
+ this.error = null;
23
+
24
+ this.showToolbar = options.showToolbar !== false;
25
+ this.allowZoom = options.allowZoom !== false;
26
+ this.allowNavigation = options.allowNavigation !== false;
27
+ this.allowDownload = options.allowDownload !== false;
28
+ this.allowPrint = options.allowPrint !== false;
29
+ this.allowRotate = options.allowRotate !== false;
30
+ this.allowSearch = options.allowSearch !== false;
31
+ this.minScale = options.minScale || 0.25;
32
+ this.maxScale = options.maxScale || 5.0;
33
+ this.backgroundColor = options.backgroundColor || null;
34
+ this.primaryColor = options.primaryColor || null;
35
+ this.pageBackground = options.pageBackground || '#FFFFFF';
36
+
71
37
  this.onPageChange = options.onPageChange || (() => {});
72
38
  this.onScaleChange = options.onScaleChange || (() => {});
73
39
  this.onLoad = options.onLoad || (() => {});
74
40
  this.onErrorCb = options.onError || (() => {});
75
41
 
76
- // État interne DOM
77
- this._containerEl = null;
78
- this._toolbarEl = null;
79
- this._viewerEl = null;
80
- this._thumbsEl = null;
81
- this._searchBarEl = null;
82
- this._mounted = false;
83
- this._pdfDoc = null;
84
- this._pageCanvases = {};
85
- this._thumbCanvases = {};
86
- this._renderTask = null;
42
+ // Layout
43
+ this._toolbarHeight = this.showToolbar ? 52 : 0;
87
44
  this._searchOpen = false;
88
45
  this._searchQuery = '';
89
- this._fullscreen = false;
46
+ this._thumbsWidth = 110;
90
47
 
91
- this._toolbarHeight = this.showToolbar ? 52 : 0;
92
- this._thumbsWidth = this.showThumbnails ? 120 : 0;
48
+ // PDF state
49
+ this._pdfDoc = null;
50
+ this._pageImages = {};
51
+ this._thumbImages = {};
52
+
53
+ // Scroll interne du viewer
54
+ this._scrollY = 0;
55
+ this._maxScrollY = 0;
56
+
57
+ // Drag interne (scroll des pages)
58
+ this._dragging = false;
59
+ this._dragStartY = 0;
60
+ this._dragStartScroll = 0;
61
+ this._pressX = undefined;
62
+ this._pressY = undefined;
63
+
64
+ // Hit areas — coordonnées MONDE (this.x + offset local)
65
+ this._tbButtons = [];
66
+ this._pageInputRect = null;
67
+ this._retryBtn = null;
68
+ this._searchCloseRect = null;
69
+ this._hoveredBtn = null;
70
+
71
+ // Spinner
72
+ this._spinAngle = 0;
73
+ this._spinRAF = null;
93
74
 
94
75
  // Couleurs Material You 3
95
76
  this.m3Colors = {
96
77
  primary: '#6750A4',
97
78
  onPrimary: '#FFFFFF',
98
- surface: '#FFFBFE',
99
79
  surfaceVariant: '#E7E0EC',
100
80
  onSurface: '#1C1B1F',
101
81
  onSurfaceVariant: '#49454F',
102
82
  outline: '#79747E',
103
83
  outlineVariant: '#CAC4D0',
104
84
  error: '#BA1A1A',
105
- shadow: '#00000040',
106
- toolbarBg: '#6750A4',
107
- toolbarText: '#FFFFFF',
108
85
  viewerBg: '#F7F2FA',
109
86
  thumbsBg: '#EFE9F4',
110
87
  thumbBorder: '#CAC4D0',
111
88
  thumbActive: '#6750A4',
112
- pageBox: '#FFFFFF',
113
- pageShadow: '#00000026',
89
+ pageShadow: 'rgba(0,0,0,0.15)',
114
90
  };
115
91
 
116
92
  // Couleurs Cupertino
117
93
  this.cupertinoColors = {
118
- primary: '#007AFF',
119
- onPrimary: '#FFFFFF',
120
- surface: '#FFFFFF',
121
- error: '#FF3B30',
122
- toolbarBg: '#F2F2F7',
123
- toolbarText: '#000000',
124
- toolbarBorder:'#C6C6C8',
125
- viewerBg: '#D1D1D6',
126
- thumbsBg: '#F2F2F7',
127
- thumbBorder: '#C6C6C8',
128
- thumbActive: '#007AFF',
129
- pageBox: '#FFFFFF',
130
- pageShadow: '#00000026',
94
+ primary: '#007AFF',
95
+ onPrimary: '#FFFFFF',
96
+ error: '#FF3B30',
97
+ toolbarBorder: '#C6C6C8',
98
+ viewerBg: '#D1D1D6',
99
+ thumbsBg: '#F2F2F7',
100
+ thumbBorder: '#C6C6C8',
101
+ thumbActive: '#007AFF',
102
+ pageShadow: 'rgba(0,0,0,0.15)',
103
+ onSurfaceVariant: '#8E8E93',
104
+ outlineVariant: '#C6C6C8',
105
+ outline: '#C6C6C8',
131
106
  };
132
- }
133
107
 
134
- get _colors() {
135
- return this.platform === 'material' ? this.m3Colors : this.cupertinoColors;
108
+ this._startSpinner();
109
+ if (this.src) this._loadPDF(this.src);
136
110
  }
137
111
 
138
- get _primary() {
139
- return this.primaryColor || this._colors.primary;
140
- }
112
+ // ─── Couleurs ────────────────────────────────────────────────────────────────
141
113
 
142
- // ─── Chargement PDF.js ────────────────────────────────────────────────────────
114
+ get _colors() { return this.platform === 'material' ? this.m3Colors : this.cupertinoColors; }
115
+ get _primary() { return this.primaryColor || this._colors.primary; }
116
+ get _isMat() { return this.platform === 'material'; }
143
117
 
144
- /**
145
- * Charge PDF.js depuis CDN si non disponible
146
- * @returns {Promise<Object>} pdfjsLib
147
- * @private
148
- */
149
- async _loadPDFJS() {
150
- if (window.pdfjsLib) return window.pdfjsLib;
118
+ // ─── Géométrie en coordonnées MONDE ──────────────────────────────────────────
151
119
 
152
- return new Promise((resolve, reject) => {
153
- const script = document.createElement('script');
154
- script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
155
- script.onload = () => {
156
- window.pdfjsLib.GlobalWorkerOptions.workerSrc =
157
- 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
158
- resolve(window.pdfjsLib);
159
- };
160
- script.onerror = () => reject(new Error('Impossible de charger PDF.js'));
161
- document.head.appendChild(script);
162
- });
120
+ get _tbRect() {
121
+ return { x: this.x, y: this.y, w: this.width, h: this._toolbarHeight };
163
122
  }
164
-
165
- // ─── Montage DOM ─────────────────────────────────────────────────────────────
166
-
167
- /**
168
- * Monte le composant DOM complet sur le canvas
169
- * @private
170
- */
171
- _mount() {
172
- if (this._mounted) return;
173
- this._mounted = true;
174
-
175
- const canvas = this.framework.canvas;
176
- const canvasRect = canvas.getBoundingClientRect();
177
- const isMat = this.platform === 'material';
178
- const colors = this._colors;
179
-
180
- // Conteneur principal
181
- this._containerEl = document.createElement('div');
182
- this._containerEl.style.cssText = `
183
- position: fixed;
184
- left: ${canvasRect.left + this.x}px;
185
- top: ${canvasRect.top + this.y}px;
186
- width: ${this.width}px;
187
- height: ${this.height}px;
188
- display: flex;
189
- flex-direction: column;
190
- z-index: 1000;
191
- overflow: hidden;
192
- border-radius: ${isMat ? '4px' : '12px'};
193
- background: ${this.backgroundColor || colors.viewerBg};
194
- box-shadow: ${isMat ? '0 4px 16px ' + colors.shadow : '0 2px 12px ' + colors.pageShadow};
195
- box-sizing: border-box;
196
- font-family: ${isMat ? "'Roboto', sans-serif" : "-apple-system, BlinkMacSystemFont, sans-serif"};
197
- `;
198
-
199
- // Toolbar
200
- if (this.showToolbar) {
201
- this._toolbarEl = this._buildToolbar();
202
- this._containerEl.appendChild(this._toolbarEl);
203
- }
204
-
205
- // Barre de recherche
206
- this._searchBarEl = this._buildSearchBar();
207
- this._containerEl.appendChild(this._searchBarEl);
208
-
209
- // Corps principal (miniatures + visionneuse)
210
- const body = document.createElement('div');
211
- body.style.cssText = `
212
- display: flex;
213
- flex: 1;
214
- overflow: hidden;
215
- `;
216
-
217
- // Panneau miniatures
218
- if (this.showThumbnails) {
219
- this._thumbsEl = this._buildThumbnailsPanel();
220
- body.appendChild(this._thumbsEl);
221
- }
222
-
223
- // Zone de visualisation
224
- this._viewerEl = document.createElement('div');
225
- this._viewerEl.style.cssText = `
226
- flex: 1;
227
- overflow: auto;
228
- display: flex;
229
- flex-direction: column;
230
- align-items: center;
231
- padding: 16px;
232
- gap: 12px;
233
- background: ${colors.viewerBg};
234
- box-sizing: border-box;
235
- scroll-behavior: smooth;
236
- `;
237
-
238
- // Événements scroll → sync page courante
239
- this._viewerEl.addEventListener('scroll', () => this._onViewerScroll());
240
-
241
- // Zoom à la molette
242
- this._viewerEl.addEventListener('wheel', (e) => {
243
- if (e.ctrlKey || e.metaKey) {
244
- e.preventDefault();
245
- const delta = e.deltaY > 0 ? -0.1 : 0.1;
246
- this._setScale(this.scale + delta);
123
+ get _sbRect() {
124
+ return { x: this.x, y: this.y + this._toolbarHeight, w: this.width, h: this._searchOpen ? 44 : 0 };
125
+ }
126
+ get _activeThumbsWidth() { return this.totalPages > 1 ? this._thumbsWidth : 0; }
127
+ get _viewerRect() {
128
+ const top = this._toolbarHeight + this._sbRect.h;
129
+ return {
130
+ x: this.x + this._activeThumbsWidth,
131
+ y: this.y + top,
132
+ w: this.width - this._activeThumbsWidth,
133
+ h: this.height - top,
134
+ };
135
+ }
136
+ get _thumbRect() {
137
+ const top = this._toolbarHeight + this._sbRect.h;
138
+ return { x: this.x, y: this.y + top, w: this._thumbsWidth, h: this.height - top };
139
+ }
140
+
141
+ // ─── Événements framework ────────────────────────────────────────────────────
142
+ // Le framework (checkComponentsAtPosition) appelle :
143
+ // onPress(x, y) sur 'start' avec comp.pressed = true
144
+ // onPress(x, y) sur 'end' avec comp.pressed = false (via onClick fallback)
145
+ // onMove(x, y) sur 'move'
146
+ //
147
+ // ATTENTION : le framework appelle onPress sur 'start' ET onClick sur 'end'.
148
+ // On surcharge onClick pour le clic final, et onPress pour le press initial.
149
+
150
+ onPress(x, y) {
151
+ // Appelé sur 'start' enregistrer la position initiale
152
+ this._pressX = x;
153
+ this._pressY = y;
154
+ this._dragging = false;
155
+ this._dragStartScroll = this._scrollY;
156
+ }
157
+
158
+ // onClick est appelé par le framework sur 'end' quand comp.pressed était true
159
+ // On le remplace ici pour recevoir les coordonnées via onPress qu'on a mémorisé.
160
+ // Mais le framework appelle onClick() sans args... On utilise donc une autre approche :
161
+ // on surcharge handleClick via _lastPressX/Y mémorisés dans onPress.
162
+
163
+ onMount() {
164
+ // Surcharger onClick pour intercepter le clic framework
165
+ // Le framework appelle comp.onClick() sans coordonnées sur 'end'.
166
+ // On utilise _pressX/_pressY mémorisés dans onPress.
167
+ this.onClick = () => {
168
+ if (!this._dragging && this._pressX !== undefined) {
169
+ this._handleClick(this._pressX, this._pressY);
247
170
  }
248
- }, { passive: false });
249
-
250
- body.appendChild(this._viewerEl);
251
- this._containerEl.appendChild(body);
252
- document.body.appendChild(this._containerEl);
253
-
254
- // Charger le PDF si src fournie
255
- if (this.src) this._loadPDF(this.src);
256
- else this._renderEmptyState();
171
+ this._pressX = undefined;
172
+ this._pressY = undefined;
173
+ this._dragging = false;
174
+ };
257
175
  }
258
176
 
259
- // ─── Toolbar ──────────────────────────────────────────────────────────────────
260
-
261
- _buildToolbar() {
262
- const isMat = this.platform === 'material';
263
- const colors = this._colors;
264
-
265
- const toolbar = document.createElement('div');
266
- toolbar.style.cssText = `
267
- display: flex;
268
- align-items: center;
269
- gap: ${isMat ? 4 : 2}px;
270
- padding: 0 ${isMat ? 12 : 8}px;
271
- height: ${this._toolbarHeight}px;
272
- background: ${this._primary};
273
- color: ${colors.onPrimary};
274
- flex-shrink: 0;
275
- box-shadow: ${isMat ? '0 2px 4px rgba(0,0,0,0.2)' : 'none'};
276
- border-bottom: ${isMat ? 'none' : `1px solid ${colors.toolbarBorder || '#C6C6C8'}`};
277
- overflow: hidden;
278
- user-select: none;
279
- -webkit-user-select: none;
280
- `;
281
-
282
- // Titre / Nom de fichier
283
- const title = document.createElement('span');
284
- title.id = 'pdf_title';
285
- title.style.cssText = `
286
- flex: 1;
287
- font-size: ${isMat ? 16 : 17}px;
288
- font-weight: ${isMat ? '500' : '600'};
289
- color: ${colors.onPrimary};
290
- overflow: hidden;
291
- text-overflow: ellipsis;
292
- white-space: nowrap;
293
- `;
294
- title.textContent = typeof this.src === 'string'
295
- ? this.src.split('/').pop() || 'Document.pdf'
296
- : 'Document.pdf';
297
- toolbar.appendChild(title);
298
-
299
- // Groupe navigation
300
- const navGroup = document.createElement('div');
301
- navGroup.style.cssText = `display: flex; align-items: center; gap: 4px;`;
302
-
303
- // Bouton page précédente
304
- const btnPrev = this._makeToolbarBtn(this._svgChevron('left'), 'Page précédente');
305
- btnPrev.id = 'pdf_btn_prev';
306
- btnPrev.addEventListener('click', () => this.goToPreviousPage());
307
-
308
- // Input page
309
- this._pageInput = document.createElement('input');
310
- this._pageInput.type = 'text';
311
- this._pageInput.value = '1';
312
- this._pageInput.style.cssText = `
313
- width: 40px;
314
- height: 28px;
315
- text-align: center;
316
- border: none;
317
- border-radius: ${isMat ? 4 : 6}px;
318
- background: rgba(255,255,255,0.2);
319
- color: white;
320
- font-size: 14px;
321
- outline: none;
322
- `;
323
- this._pageInput.addEventListener('change', () => {
324
- const p = parseInt(this._pageInput.value);
325
- if (p >= 1 && p <= this.totalPages) this.goToPage(p);
326
- else this._pageInput.value = this.currentPage;
327
- });
328
-
329
- this._pageTotalLabel = document.createElement('span');
330
- this._pageTotalLabel.style.cssText = `font-size: 14px; color: rgba(255,255,255,0.8);`;
331
- this._pageTotalLabel.textContent = '/ -';
332
-
333
- // Bouton page suivante
334
- const btnNext = this._makeToolbarBtn(this._svgChevron('right'), 'Page suivante');
335
- btnNext.id = 'pdf_btn_next';
336
- btnNext.addEventListener('click', () => this.goToNextPage());
337
-
338
- navGroup.appendChild(btnPrev);
339
- navGroup.appendChild(this._pageInput);
340
- navGroup.appendChild(this._pageTotalLabel);
341
- navGroup.appendChild(btnNext);
342
- toolbar.appendChild(navGroup);
343
-
344
- // Séparateur
345
- toolbar.appendChild(this._makeSep());
346
-
347
- // Zoom
348
- const zoomGroup = document.createElement('div');
349
- zoomGroup.style.cssText = `display: flex; align-items: center; gap: 4px;`;
350
-
351
- const btnZoomOut = this._makeToolbarBtn('−', 'Dézoomer');
352
- btnZoomOut.style.fontSize = '20px';
353
- btnZoomOut.addEventListener('click', () => this._setScale(this.scale - 0.25));
354
-
355
- this._scaleLabel = document.createElement('span');
356
- this._scaleLabel.style.cssText = `
357
- font-size: 13px; color: rgba(255,255,255,0.9);
358
- min-width: 40px; text-align: center;
359
- `;
360
- this._scaleLabel.textContent = '100%';
361
-
362
- const btnZoomIn = this._makeToolbarBtn('+', 'Zoomer');
363
- btnZoomIn.style.fontSize = '20px';
364
- btnZoomIn.addEventListener('click', () => this._setScale(this.scale + 0.25));
365
-
366
- // Bouton zoom automatique
367
- const btnZoomFit = this._makeToolbarBtn(this._svgFit(), 'Ajuster à la page');
368
- btnZoomFit.addEventListener('click', () => this._fitToWidth());
369
-
370
- zoomGroup.appendChild(btnZoomOut);
371
- zoomGroup.appendChild(this._scaleLabel);
372
- zoomGroup.appendChild(btnZoomIn);
373
- zoomGroup.appendChild(btnZoomFit);
374
- toolbar.appendChild(zoomGroup);
375
-
376
- // Outils supplémentaires
377
- if (this.allowRotate || this.allowSearch || this.allowDownload || this.allowPrint || this.allowFullscreen) {
378
- toolbar.appendChild(this._makeSep());
177
+ onMove(x, y) {
178
+ this._checkHover(x, y);
179
+
180
+ // Drag interne du viewer si le press initial était dans le viewer
181
+ if (this._pressX !== undefined) {
182
+ const vr = this._viewerRect;
183
+ const inViewer = this._pressX >= vr.x && this._pressX <= vr.x + vr.w &&
184
+ this._pressY >= vr.y && this._pressY <= vr.y + vr.h;
185
+ if (inViewer) {
186
+ const dy = Math.abs(y - this._pressY);
187
+ if (dy > 5 || this._dragging) {
188
+ if (!this._dragging) {
189
+ this._dragging = true;
190
+ this._dragStartY = this._pressY;
191
+ this._dragStartScroll = this._scrollY;
192
+ }
193
+ this._scrollY = Math.max(0, Math.min(this._maxScrollY,
194
+ this._dragStartScroll + (this._dragStartY - y)));
195
+ this._syncPage();
196
+ this.markDirty();
197
+ }
198
+ }
379
199
  }
200
+ }
380
201
 
381
- if (this.allowRotate) {
382
- const btnRotate = this._makeToolbarBtn(this._svgRotate(), 'Rotation 90°');
383
- btnRotate.addEventListener('click', () => this._rotate());
384
- toolbar.appendChild(btnRotate);
385
- }
202
+ // ─── Logique de clic ─────────────────────────────────────────────────────────
386
203
 
387
- if (this.allowSearch) {
388
- const btnSearch = this._makeToolbarBtn(this._svgSearch(), 'Rechercher');
389
- btnSearch.addEventListener('click', () => this._toggleSearch());
390
- toolbar.appendChild(btnSearch);
204
+ _handleClick(x, y) {
205
+ // Retry
206
+ if (this._retryBtn) {
207
+ const r = this._retryBtn;
208
+ if (x >= r.x && x <= r.x+r.w && y >= r.y && y <= r.y+r.h) {
209
+ if (this.src) this._loadPDF(this.src); return;
210
+ }
391
211
  }
392
212
 
393
- if (this.allowDownload) {
394
- const btnDl = this._makeToolbarBtn(this._svgDownload(), 'Télécharger');
395
- btnDl.addEventListener('click', () => this._download());
396
- toolbar.appendChild(btnDl);
213
+ // Fermer recherche
214
+ if (this._searchCloseRect) {
215
+ const r = this._searchCloseRect;
216
+ if (x >= r.x && x <= r.x+r.w && y >= r.y && y <= r.y+r.h) {
217
+ this._toggleSearch(); return;
218
+ }
397
219
  }
398
220
 
399
- if (this.allowPrint) {
400
- const btnPrint = this._makeToolbarBtn(this._svgPrint(), 'Imprimer');
401
- btnPrint.addEventListener('click', () => this._print());
402
- toolbar.appendChild(btnPrint);
221
+ // Boutons toolbar
222
+ for (const b of this._tbButtons) {
223
+ if (!b.disabled && x >= b.x && x <= b.x+b.w && y >= b.y && y <= b.y+b.h) {
224
+ this._handleBtn(b.id); return;
225
+ }
403
226
  }
404
227
 
405
- if (this.allowFullscreen) {
406
- const btnFs = this._makeToolbarBtn(this._svgFullscreen(), 'Plein écran');
407
- btnFs.addEventListener('click', () => this._toggleFullscreen());
408
- toolbar.appendChild(btnFs);
228
+ // Indicateur de page
229
+ if (this._pageInputRect) {
230
+ const r = this._pageInputRect;
231
+ if (x >= r.x && x <= r.x+r.w && y >= r.y && y <= r.y+r.h) {
232
+ this._promptPage(); return;
233
+ }
409
234
  }
410
235
 
411
- return toolbar;
236
+ // Miniatures
237
+ if (this.totalPages > 1) {
238
+ const tr = this._thumbRect;
239
+ if (x >= tr.x && x <= tr.x+tr.w && y >= tr.y && y <= tr.y+tr.h) {
240
+ let ty = tr.y + 8;
241
+ for (let p = 1; p <= this.totalPages; p++) {
242
+ const img = this._thumbImages[p];
243
+ const tw = tr.w - 16;
244
+ const th = img ? Math.round(img.height * tw / img.width) : 70;
245
+ if (y >= ty && y <= ty + th) { this.goToPage(p); return; }
246
+ ty += th + 18;
247
+ }
248
+ }
249
+ }
412
250
  }
413
251
 
414
- _makeToolbarBtn(content, title) {
415
- const btn = document.createElement('button');
416
- btn.innerHTML = content;
417
- btn.title = title;
418
- btn.style.cssText = `
419
- display: inline-flex;
420
- align-items: center;
421
- justify-content: center;
422
- width: 34px;
423
- height: 34px;
424
- border: none;
425
- border-radius: ${this.platform === 'material' ? 4 : 6}px;
426
- background: transparent;
427
- color: white;
428
- cursor: pointer;
429
- padding: 0;
430
- transition: background 0.15s;
431
- flex-shrink: 0;
432
- `;
433
- btn.addEventListener('mouseenter', () => btn.style.background = 'rgba(255,255,255,0.2)');
434
- btn.addEventListener('mouseleave', () => btn.style.background = 'transparent');
435
- return btn;
252
+ _checkHover(x, y) {
253
+ let hover = null;
254
+ for (const b of this._tbButtons) {
255
+ if (!b.disabled && x >= b.x && x <= b.x+b.w && y >= b.y && y <= b.y+b.h) {
256
+ hover = b.id; break;
257
+ }
258
+ }
259
+ if (!hover && this._searchCloseRect) {
260
+ const r = this._searchCloseRect;
261
+ if (x >= r.x && x <= r.x+r.w && y >= r.y && y <= r.y+r.h) hover = 'searchClose';
262
+ }
263
+ if (!hover && this._retryBtn) {
264
+ const r = this._retryBtn;
265
+ if (x >= r.x && x <= r.x+r.w && y >= r.y && y <= r.y+r.h) hover = 'retry';
266
+ }
267
+ if (hover !== this._hoveredBtn) { this._hoveredBtn = hover; this.markDirty(); }
436
268
  }
437
269
 
438
- _makeSep() {
439
- const sep = document.createElement('div');
440
- sep.style.cssText = `width:1px; height:24px; background:rgba(255,255,255,0.3); margin:0 4px; flex-shrink:0;`;
441
- return sep;
442
- }
270
+ // ─── Spinner ─────────────────────────────────────────────────────────────────
443
271
 
444
- // ─── Barre de recherche ───────────────────────────────────────────────────────
445
-
446
- _buildSearchBar() {
447
- const isMat = this.platform === 'material';
448
- const bar = document.createElement('div');
449
- bar.style.cssText = `
450
- display: none;
451
- align-items: center;
452
- gap: 8px;
453
- padding: 8px 12px;
454
- background: ${isMat ? this.m3Colors.surfaceVariant : '#F2F2F7'};
455
- border-bottom: 1px solid ${isMat ? this.m3Colors.outlineVariant : '#C6C6C8'};
456
- flex-shrink: 0;
457
- `;
458
-
459
- const input = document.createElement('input');
460
- input.type = 'text';
461
- input.placeholder = 'Rechercher dans le document...';
462
- input.style.cssText = `
463
- flex: 1;
464
- border: 1px solid ${isMat ? this.m3Colors.outline : '#C6C6C8'};
465
- border-radius: ${isMat ? 4 : 8}px;
466
- padding: 6px 10px;
467
- font-size: 14px;
468
- outline: none;
469
- background: white;
470
- `;
471
-
472
- const btnClose = document.createElement('button');
473
- btnClose.innerHTML = '✕';
474
- btnClose.style.cssText = `
475
- border: none; background: transparent; cursor: pointer;
476
- font-size: 16px; color: ${isMat ? this.m3Colors.onSurfaceVariant : '#8E8E93'};
477
- `;
478
- btnClose.addEventListener('click', () => this._toggleSearch());
479
-
480
- bar.appendChild(input);
481
- bar.appendChild(btnClose);
482
- this._searchInput = input;
483
- return bar;
272
+ _startSpinner() {
273
+ const tick = () => {
274
+ if (this.loading) { this._spinAngle = (this._spinAngle + 0.08) % (Math.PI * 2); this.markDirty(); }
275
+ this._spinRAF = requestAnimationFrame(tick);
276
+ };
277
+ this._spinRAF = requestAnimationFrame(tick);
484
278
  }
485
279
 
486
- _toggleSearch() {
487
- this._searchOpen = !this._searchOpen;
488
- this._searchBarEl.style.display = this._searchOpen ? 'flex' : 'none';
489
- if (this._searchOpen) this._searchInput.focus();
490
- }
280
+ // ─── PDF.js ──────────────────────────────────────────────────────────────────
491
281
 
492
- // ─── Panneau miniatures ───────────────────────────────────────────────────────
493
-
494
- _buildThumbnailsPanel() {
495
- const isMat = this.platform === 'material';
496
- const panel = document.createElement('div');
497
- panel.style.cssText = `
498
- width: ${this._thumbsWidth}px;
499
- height: 100%;
500
- overflow-y: auto;
501
- background: ${this._colors.thumbsBg};
502
- border-right: 1px solid ${this._colors.thumbBorder};
503
- padding: 8px;
504
- box-sizing: border-box;
505
- flex-shrink: 0;
506
- display: flex;
507
- flex-direction: column;
508
- gap: 8px;
509
- align-items: center;
510
- scrollbar-width: thin;
511
- `;
512
- return panel;
282
+ async _loadPDFJS() {
283
+ if (window.pdfjsLib) return window.pdfjsLib;
284
+ return new Promise((resolve, reject) => {
285
+ const s = document.createElement('script');
286
+ s.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
287
+ s.onload = () => {
288
+ window.pdfjsLib.GlobalWorkerOptions.workerSrc =
289
+ 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
290
+ resolve(window.pdfjsLib);
291
+ };
292
+ s.onerror = () => reject(new Error('Impossible de charger PDF.js'));
293
+ document.head.appendChild(s);
294
+ });
513
295
  }
514
296
 
515
- // ─── Chargement PDF ───────────────────────────────────────────────────────────
516
-
517
- /**
518
- * Charge et affiche un PDF
519
- * @param {string|Uint8Array} src
520
- */
521
297
  async _loadPDF(src) {
522
- this.loading = true;
523
- this.error = null;
524
- this._renderLoadingState();
525
-
298
+ this.loading = true; this.error = null;
299
+ this._pageImages = {}; this._thumbImages = {}; this._scrollY = 0;
300
+ this.markDirty();
526
301
  try {
527
- const pdfjsLib = await this._loadPDFJS();
528
- const loadingTask = pdfjsLib.getDocument(
529
- typeof src === 'string' ? src : { data: src }
530
- );
531
- this._pdfDoc = await loadingTask.promise;
532
- this.totalPages = this._pdfDoc.numPages;
533
- this.loading = false;
534
-
535
- // Met à jour toolbar
536
- if (this._pageTotalLabel) this._pageTotalLabel.textContent = `/ ${this.totalPages}`;
537
-
538
- // Met à jour le titre si URL
539
- if (typeof src === 'string' && this._containerEl) {
540
- const t = this._containerEl.querySelector('#pdf_title');
541
- if (t) t.textContent = src.split('/').pop();
542
- }
543
-
544
- // Vide la visionneuse
545
- this._viewerEl.innerHTML = '';
546
-
547
- // Rend toutes les pages
302
+ const lib = await this._loadPDFJS();
303
+ const task = lib.getDocument(typeof src === 'string' ? src : { data: src });
304
+ this._pdfDoc = await task.promise;
305
+ this.totalPages = this._pdfDoc.numPages;
306
+ const promises = [];
548
307
  for (let p = 1; p <= this.totalPages; p++) {
549
- await this._renderPage(p);
550
- if (this.showThumbnails) await this._renderThumbnail(p);
308
+ promises.push(this._renderPage(p), this._renderThumb(p));
551
309
  }
552
-
553
- // Aller à la page initiale
554
- this.scrollToPage(this.currentPage);
310
+ await Promise.all(promises);
311
+ this.loading = false;
312
+ this._computeMaxScroll();
555
313
  this.onLoad(this.totalPages);
556
-
314
+ this.markDirty();
557
315
  } catch (err) {
558
316
  this.loading = false;
559
- this.error = err.message || 'Erreur lors du chargement du PDF';
560
- this._renderErrorState();
317
+ this.error = err.message || 'Erreur de chargement';
561
318
  this.onErrorCb(this.error);
319
+ this.markDirty();
562
320
  }
563
321
  }
564
322
 
565
- /**
566
- * Rend une page PDF dans la zone de visualisation
567
- * @param {number} pageNum
568
- * @private
569
- */
570
- async _renderPage(pageNum) {
571
- const page = await this._pdfDoc.getPage(pageNum);
572
- const viewport = page.getViewport({ scale: this.scale, rotation: this.rotation });
573
-
574
- // Wrapper de page
575
- const pageWrapper = document.createElement('div');
576
- pageWrapper.id = `pdf_page_${pageNum}`;
577
- pageWrapper.dataset.page = pageNum;
578
- pageWrapper.style.cssText = `
579
- position: relative;
580
- box-shadow: 0 2px 8px ${this._colors.pageShadow};
581
- flex-shrink: 0;
582
- background: ${this.pageBackground};
583
- line-height: 0;
584
- `;
585
-
586
- const pageCanvas = document.createElement('canvas');
587
- pageCanvas.width = viewport.width;
588
- pageCanvas.height = viewport.height;
589
- pageCanvas.style.cssText = `display: block; max-width: 100%;`;
590
-
591
- pageWrapper.appendChild(pageCanvas);
592
-
593
- // Numéro de page (en bas)
594
- const pageLabel = document.createElement('div');
595
- pageLabel.style.cssText = `
596
- position: absolute;
597
- bottom: 6px;
598
- right: 10px;
599
- font-size: 11px;
600
- color: #888;
601
- line-height: 1;
602
- pointer-events: none;
603
- `;
604
- pageLabel.textContent = `${pageNum} / ${this.totalPages}`;
605
- pageWrapper.appendChild(pageLabel);
606
-
607
- this._viewerEl.appendChild(pageWrapper);
608
- this._pageCanvases[pageNum] = pageCanvas;
609
-
610
- const ctx = pageCanvas.getContext('2d');
611
- await page.render({ canvasContext: ctx, viewport }).promise;
323
+ async _renderPage(p) {
324
+ try {
325
+ const page = await this._pdfDoc.getPage(p);
326
+ const vp = page.getViewport({ scale: this.scale, rotation: this.rotation });
327
+ const off = new OffscreenCanvas(Math.ceil(vp.width), Math.ceil(vp.height));
328
+ await page.render({ canvasContext: off.getContext('2d'), viewport: vp }).promise;
329
+ this._pageImages[p] = await createImageBitmap(off);
330
+ } catch (err) { console.error(`Erreur page ${p}:`, err); }
612
331
  }
613
332
 
614
- /**
615
- * Rend une miniature de page
616
- * @param {number} pageNum
617
- * @private
618
- */
619
- async _renderThumbnail(pageNum) {
620
- if (!this._thumbsEl) return;
621
- const page = await this._pdfDoc.getPage(pageNum);
622
- const viewport = page.getViewport({ scale: 0.2 });
623
-
624
- const thumbWrapper = document.createElement('div');
625
- thumbWrapper.id = `pdf_thumb_${pageNum}`;
626
- thumbWrapper.dataset.page = pageNum;
627
- thumbWrapper.style.cssText = `
628
- cursor: pointer;
629
- border: 2px solid ${pageNum === this.currentPage ? this._colors.thumbActive : this._colors.thumbBorder};
630
- border-radius: 4px;
631
- overflow: hidden;
632
- transition: border-color 0.2s;
633
- `;
634
- thumbWrapper.addEventListener('click', () => {
635
- this.goToPage(pageNum);
636
- this._highlightThumbnail(pageNum);
637
- });
638
-
639
- const thumbCanvas = document.createElement('canvas');
640
- thumbCanvas.width = viewport.width;
641
- thumbCanvas.height = viewport.height;
642
- thumbCanvas.style.cssText = `display: block; width: 100%;`;
643
- thumbWrapper.appendChild(thumbCanvas);
644
-
645
- const numLabel = document.createElement('div');
646
- numLabel.style.cssText = `
647
- text-align: center;
648
- font-size: 10px;
649
- color: #666;
650
- padding: 2px;
651
- background: white;
652
- `;
653
- numLabel.textContent = pageNum;
654
- thumbWrapper.appendChild(numLabel);
655
-
656
- this._thumbsEl.appendChild(thumbWrapper);
657
- this._thumbCanvases[pageNum] = thumbWrapper;
658
-
659
- const ctx = thumbCanvas.getContext('2d');
660
- await page.render({ canvasContext: ctx, viewport }).promise;
333
+ async _renderThumb(p) {
334
+ try {
335
+ const page = await this._pdfDoc.getPage(p);
336
+ const vp = page.getViewport({ scale: 0.18, rotation: this.rotation });
337
+ const off = new OffscreenCanvas(Math.ceil(vp.width), Math.ceil(vp.height));
338
+ await page.render({ canvasContext: off.getContext('2d'), viewport: vp }).promise;
339
+ this._thumbImages[p] = await createImageBitmap(off);
340
+ } catch (err) { console.error(`Erreur miniature ${p}:`, err); }
661
341
  }
662
342
 
663
- /**
664
- * Met en surbrillance la miniature de la page active
665
- * @param {number} pageNum
666
- * @private
667
- */
668
- _highlightThumbnail(pageNum) {
669
- if (!this._thumbsEl) return;
670
- this._thumbsEl.querySelectorAll('[data-page]').forEach(el => {
671
- el.style.borderColor = this._colors.thumbBorder;
672
- });
673
- const active = this._thumbsEl.querySelector(`#pdf_thumb_${pageNum}`);
674
- if (active) active.style.borderColor = this._colors.thumbActive;
343
+ async _reRenderAll() {
344
+ if (!this._pdfDoc) return;
345
+ this._pageImages = {}; this._thumbImages = {};
346
+ this.loading = true; this.markDirty();
347
+ const promises = [];
348
+ for (let p = 1; p <= this.totalPages; p++) {
349
+ promises.push(this._renderPage(p), this._renderThumb(p));
350
+ }
351
+ await Promise.all(promises);
352
+ this.loading = false;
353
+ this._computeMaxScroll();
354
+ this._scrollY = Math.min(this._scrollY, this._maxScrollY);
355
+ this.markDirty();
675
356
  }
676
357
 
677
- /**
678
- * Détecte la page visible lors du scroll
679
- * @private
680
- */
681
- _onViewerScroll() {
682
- if (!this._viewerEl) return;
683
- const scrollTop = this._viewerEl.scrollTop + this._viewerEl.clientHeight / 2;
684
- let nearest = 1;
685
- let nearestDist = Infinity;
686
-
687
- this._viewerEl.querySelectorAll('[data-page]').forEach(el => {
688
- const p = parseInt(el.dataset.page);
689
- const dist = Math.abs(el.offsetTop - scrollTop);
690
- if (dist < nearestDist) { nearestDist = dist; nearest = p; }
691
- });
358
+ // ─── Scroll interne ───────────────────────────────────────────────────────────
692
359
 
693
- if (nearest !== this.currentPage) {
694
- this.currentPage = nearest;
695
- if (this._pageInput) this._pageInput.value = nearest;
696
- this.onPageChange(this.currentPage, this.totalPages);
697
- this._highlightThumbnail(nearest);
360
+ _computeMaxScroll() {
361
+ const vr = this._viewerRect;
362
+ let total = 16;
363
+ for (let p = 1; p <= this.totalPages; p++) {
364
+ total += (this._pageImages[p] ? this._pageImages[p].height : 200) + 16;
698
365
  }
366
+ this._maxScrollY = Math.max(0, total - vr.h);
699
367
  }
700
368
 
701
- // ─── États visuels (vide, chargement, erreur) ─────────────────────────────────
702
-
703
- _renderEmptyState() {
704
- this._viewerEl.innerHTML = '';
705
- const wrap = document.createElement('div');
706
- wrap.style.cssText = `
707
- display: flex; flex-direction: column; align-items: center;
708
- justify-content: center; height: 100%; gap: 16px; opacity: 0.5;
709
- `;
710
- wrap.innerHTML = `
711
- <svg width="64" height="64" viewBox="0 0 24 24" fill="${this._colors.onSurfaceVariant || '#49454F'}">
712
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
713
- <polyline points="14,2 14,8 20,8" fill="none" stroke="white" stroke-width="1.5"/>
714
- <text x="7" y="19" font-size="5" fill="white" font-weight="bold">PDF</text>
715
- </svg>
716
- <p style="margin:0; font-size:15px; color:${this._colors.onSurfaceVariant || '#49454F'}">
717
- Aucun document chargé
718
- </p>
719
- `;
720
- this._viewerEl.appendChild(wrap);
369
+ _pageScrollOffset(pageNum) {
370
+ let y = 16;
371
+ for (let p = 1; p < pageNum; p++) {
372
+ y += (this._pageImages[p] ? this._pageImages[p].height : 200) + 16;
373
+ }
374
+ return y;
721
375
  }
722
376
 
723
- _renderLoadingState() {
724
- this._viewerEl.innerHTML = '';
725
- const wrap = document.createElement('div');
726
- wrap.style.cssText = `
727
- display: flex; flex-direction: column; align-items: center;
728
- justify-content: center; height: 100%; gap: 16px;
729
- `;
730
-
731
- const spinner = document.createElement('div');
732
- spinner.style.cssText = `
733
- width: 40px; height: 40px;
734
- border: 4px solid ${this._colors.thumbBorder};
735
- border-top-color: ${this._primary};
736
- border-radius: 50%;
737
- animation: pdf_spin 0.8s linear infinite;
738
- `;
739
-
740
- if (!document.querySelector('#pdf_spinner_style')) {
741
- const s = document.createElement('style');
742
- s.id = 'pdf_spinner_style';
743
- s.textContent = `@keyframes pdf_spin { to { transform: rotate(360deg); } }`;
744
- document.head.appendChild(s);
377
+ _syncPage() {
378
+ if (!this.totalPages) return;
379
+ const mid = this._scrollY + this._viewerRect.h / 2;
380
+ let y = 16;
381
+ for (let p = 1; p <= this.totalPages; p++) {
382
+ const h = this._pageImages[p] ? this._pageImages[p].height : 200;
383
+ if (mid <= y + h || p === this.totalPages) {
384
+ if (p !== this.currentPage) { this.currentPage = p; this.onPageChange(p, this.totalPages); }
385
+ return;
386
+ }
387
+ y += h + 16;
745
388
  }
389
+ }
746
390
 
747
- const label = document.createElement('p');
748
- label.style.cssText = `margin:0; font-size:15px; color:${this._colors.onSurfaceVariant || '#49454F'};`;
749
- label.textContent = 'Chargement du document...';
391
+ // ─── Dessin ───────────────────────────────────────────────────────────────────
750
392
 
751
- wrap.appendChild(spinner);
752
- wrap.appendChild(label);
753
- this._viewerEl.appendChild(wrap);
754
- }
393
+ draw(ctx) {
394
+ ctx.save();
395
+ ctx.beginPath();
396
+ this._rr(ctx, this.x, this.y, this.width, this.height, this._isMat ? 4 : 12);
397
+ ctx.clip();
755
398
 
756
- _renderErrorState() {
757
- this._viewerEl.innerHTML = '';
758
- const wrap = document.createElement('div');
759
- wrap.style.cssText = `
760
- display: flex; flex-direction: column; align-items: center;
761
- justify-content: center; height: 100%; gap: 16px; padding: 24px;
762
- `;
763
- wrap.innerHTML = `
764
- <svg width="48" height="48" viewBox="0 0 24 24" fill="${this._colors.error || '#BA1A1A'}">
765
- <circle cx="12" cy="12" r="10"/>
766
- <line x1="12" y1="8" x2="12" y2="12" stroke="white" stroke-width="2"/>
767
- <circle cx="12" cy="16" r="1" fill="white"/>
768
- </svg>
769
- <p style="margin:0; font-size:15px; color:${this._colors.error || '#BA1A1A'}; text-align:center;">
770
- ${this.error}
771
- </p>
772
- `;
773
-
774
- // Bouton réessayer
775
- const retryBtn = document.createElement('button');
776
- retryBtn.textContent = 'Réessayer';
777
- retryBtn.style.cssText = `
778
- padding: 8px 20px;
779
- background: ${this._primary};
780
- color: white;
781
- border: none;
782
- border-radius: ${this.platform === 'material' ? 20 : 8}px;
783
- cursor: pointer;
784
- font-size: 14px;
785
- `;
786
- retryBtn.addEventListener('click', () => {
787
- if (this.src) this._loadPDF(this.src);
788
- });
399
+ ctx.fillStyle = this.backgroundColor || this._colors.viewerBg;
400
+ ctx.fillRect(this.x, this.y, this.width, this.height);
789
401
 
790
- wrap.appendChild(retryBtn);
791
- this._viewerEl.appendChild(wrap);
792
- }
402
+ if (this.totalPages > 1) this._drawThumbs(ctx);
403
+ this._drawViewer(ctx);
404
+ if (this.showToolbar) this._drawToolbar(ctx);
405
+ if (this._searchOpen) this._drawSearchBar(ctx);
793
406
 
794
- // ─── Actions ──────────────────────────────────────────────────────────────────
795
-
796
- /**
797
- * Change le niveau de zoom
798
- * @param {number} newScale
799
- * @private
800
- */
801
- _setScale(newScale) {
802
- this.scale = Math.max(this.minScale, Math.min(this.maxScale, newScale));
803
- this.scale = Math.round(this.scale * 100) / 100;
804
- if (this._scaleLabel) this._scaleLabel.textContent = `${Math.round(this.scale * 100)}%`;
805
- this.onScaleChange(this.scale);
806
- if (this._pdfDoc) this._reRenderAll();
807
- }
407
+ ctx.strokeStyle = this._isMat ? 'rgba(0,0,0,0.12)' : (this._colors.toolbarBorder || '#C6C6C8');
408
+ ctx.lineWidth = 1;
409
+ ctx.beginPath();
410
+ this._rr(ctx, this.x, this.y, this.width, this.height, this._isMat ? 4 : 12);
411
+ ctx.stroke();
808
412
 
809
- /**
810
- * Ajuste le zoom à la largeur du viewer
811
- * @private
812
- */
813
- async _fitToWidth() {
814
- if (!this._pdfDoc) return;
815
- const page = await this._pdfDoc.getPage(1);
816
- const viewport = page.getViewport({ scale: 1 });
817
- const availW = this._viewerEl.clientWidth - 32;
818
- const ratio = availW / viewport.width;
819
- this._setScale(ratio);
413
+ ctx.restore();
820
414
  }
821
415
 
822
- /**
823
- * Re-rend toutes les pages (après zoom ou rotation)
824
- * @private
825
- */
826
- async _reRenderAll() {
827
- this._viewerEl.innerHTML = '';
828
- this._pageCanvases = {};
829
- for (let p = 1; p <= this.totalPages; p++) {
830
- await this._renderPage(p);
831
- }
832
- this.scrollToPage(this.currentPage);
833
- }
416
+ _drawViewer(ctx) {
417
+ const vr = this._viewerRect;
418
+ ctx.save();
419
+ ctx.beginPath(); ctx.rect(vr.x, vr.y, vr.w, vr.h); ctx.clip();
420
+ ctx.fillStyle = this._colors.viewerBg;
421
+ ctx.fillRect(vr.x, vr.y, vr.w, vr.h);
834
422
 
835
- /**
836
- * Effectue une rotation de 90°
837
- * @private
838
- */
839
- _rotate() {
840
- this.rotation = (this.rotation + 90) % 360;
841
- if (this._pdfDoc) this._reRenderAll();
842
- }
423
+ const hasImages = Object.keys(this._pageImages).length > 0;
424
+ if (this.loading && !hasImages) this._drawLoading(ctx, vr);
425
+ else if (this.error && !hasImages) this._drawError(ctx, vr);
426
+ else if (!this.totalPages && !this.loading) this._drawEmpty(ctx, vr);
427
+ else this._drawPages(ctx, vr);
843
428
 
844
- /**
845
- * Télécharge le PDF
846
- * @private
847
- */
848
- _download() {
849
- if (!this.src || typeof this.src !== 'string') return;
850
- const a = document.createElement('a');
851
- a.href = this.src;
852
- a.download = this.src.split('/').pop() || 'document.pdf';
853
- a.click();
429
+ ctx.restore();
854
430
  }
855
431
 
856
- /**
857
- * Imprime le PDF
858
- * @private
859
- */
860
- _print() {
861
- if (!this.src || typeof this.src !== 'string') return;
862
- const w = window.open(this.src);
863
- if (w) w.addEventListener('load', () => w.print());
864
- }
432
+ _drawPages(ctx, vr) {
433
+ const pad = 16;
434
+ let y = vr.y + pad - this._scrollY;
865
435
 
866
- /**
867
- * Bascule en plein écran
868
- * @private
869
- */
870
- _toggleFullscreen() {
871
- if (!this._containerEl) return;
872
- this._fullscreen = !this._fullscreen;
873
- if (this._fullscreen) {
874
- this._savedStyle = {
875
- left: this._containerEl.style.left,
876
- top: this._containerEl.style.top,
877
- width:this._containerEl.style.width,
878
- height:this._containerEl.style.height,
879
- zIndex:this._containerEl.style.zIndex,
880
- borderRadius: this._containerEl.style.borderRadius,
881
- };
882
- this._containerEl.style.cssText += `
883
- left: 0 !important;
884
- top: 0 !important;
885
- width: 100vw !important;
886
- height: 100vh !important;
887
- z-index: 99999 !important;
888
- border-radius: 0 !important;
889
- `;
890
- } else {
891
- if (this._savedStyle) {
892
- Object.assign(this._containerEl.style, this._savedStyle);
436
+ for (let p = 1; p <= this.totalPages; p++) {
437
+ const img = this._pageImages[p];
438
+ const ph = img ? img.height : 200;
439
+ const pw = img ? Math.min(img.width, vr.w - pad * 2) : vr.w - pad * 2;
440
+ const px = vr.x + (vr.w - pw) / 2;
441
+
442
+ if (y + ph >= vr.y && y <= vr.y + vr.h) {
443
+ ctx.shadowColor = this._colors.pageShadow;
444
+ ctx.shadowBlur = 8; ctx.shadowOffsetY = 2;
445
+ ctx.fillStyle = this.pageBackground;
446
+ ctx.fillRect(px, y, pw, ph);
447
+ ctx.shadowBlur = 0; ctx.shadowOffsetY = 0;
448
+
449
+ if (img) {
450
+ ctx.drawImage(img, 0, 0, img.width, img.height, px, y, pw, ph);
451
+ } else {
452
+ ctx.fillStyle = '#ccc'; ctx.font = '13px sans-serif';
453
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
454
+ ctx.fillText(`Page ${p}…`, px + pw/2, y + ph/2);
455
+ }
456
+
457
+ ctx.fillStyle = 'rgba(0,0,0,0.28)'; ctx.font = '11px sans-serif';
458
+ ctx.textAlign = 'right'; ctx.textBaseline = 'bottom';
459
+ ctx.fillText(`${p} / ${this.totalPages}`, px + pw - 6, y + ph - 4);
893
460
  }
461
+ y += ph + pad;
894
462
  }
463
+ this._drawScrollbar(ctx, vr);
464
+ }
465
+
466
+ _drawScrollbar(ctx, vr) {
467
+ if (this._maxScrollY <= 0) return;
468
+ const tw = 5, tr = 3;
469
+ const tx = vr.x + vr.w - tw - 3;
470
+ const ty = vr.y + 4, th = vr.h - 8;
471
+ const ratio = vr.h / (vr.h + this._maxScrollY);
472
+ const thumbH = Math.max(24, th * ratio);
473
+ const thumbY = ty + (this._scrollY / this._maxScrollY) * (th - thumbH);
474
+ ctx.fillStyle = 'rgba(0,0,0,0.08)'; this._rr(ctx, tx, ty, tw, th, tr); ctx.fill();
475
+ ctx.fillStyle = 'rgba(0,0,0,0.32)'; this._rr(ctx, tx, thumbY, tw, thumbH, tr); ctx.fill();
476
+ }
477
+
478
+ _drawLoading(ctx, vr) {
479
+ const cx = vr.x + vr.w/2, cy = vr.y + vr.h/2 - 20;
480
+ ctx.strokeStyle = 'rgba(0,0,0,0.1)'; ctx.lineWidth = 4;
481
+ ctx.beginPath(); ctx.arc(cx, cy, 22, 0, Math.PI*2); ctx.stroke();
482
+ ctx.strokeStyle = this._primary; ctx.lineWidth = 4; ctx.lineCap = 'round';
483
+ ctx.beginPath(); ctx.arc(cx, cy, 22, this._spinAngle, this._spinAngle + 1.3); ctx.stroke();
484
+ ctx.fillStyle = this._colors.onSurfaceVariant || '#49454F';
485
+ ctx.font = '13px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
486
+ ctx.fillText('Chargement...', cx, cy + 32);
487
+ }
488
+
489
+ _drawEmpty(ctx, vr) {
490
+ const cx = vr.x + vr.w/2, cy = vr.y + vr.h/2 - 20;
491
+ ctx.globalAlpha = 0.35; ctx.fillStyle = this._colors.onSurfaceVariant || '#49454F';
492
+ ctx.beginPath();
493
+ const fx = cx-20, fy = cy-26;
494
+ ctx.moveTo(fx+6,fy); ctx.lineTo(fx+28,fy); ctx.lineTo(fx+40,fy+14);
495
+ ctx.lineTo(fx+40,fy+54); ctx.quadraticCurveTo(fx+40,fy+58,fx+36,fy+58);
496
+ ctx.lineTo(fx+4,fy+58); ctx.quadraticCurveTo(fx,fy+58,fx,fy+54);
497
+ ctx.lineTo(fx,fy+6); ctx.quadraticCurveTo(fx,fy,fx+6,fy);
498
+ ctx.closePath(); ctx.fill();
499
+ ctx.globalAlpha = 1; ctx.fillStyle = this._colors.onSurfaceVariant || '#49454F';
500
+ ctx.font = '13px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
501
+ ctx.fillText('Aucun document chargé', cx, cy + 42);
502
+ }
503
+
504
+ _drawError(ctx, vr) {
505
+ const cx = vr.x + vr.w/2, cy = vr.y + vr.h/2 - 30;
506
+ const err = this._colors.error || '#BA1A1A';
507
+ ctx.fillStyle = err; ctx.beginPath(); ctx.arc(cx, cy, 24, 0, Math.PI*2); ctx.fill();
508
+ ctx.fillStyle = '#fff'; ctx.font = 'bold 26px sans-serif';
509
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('!', cx, cy);
510
+ ctx.fillStyle = err; ctx.font = '13px sans-serif'; ctx.textBaseline = 'top';
511
+ ctx.fillText((this.error || 'Erreur').substring(0, 60), cx, cy + 34);
512
+ const bx = cx - 48, by = cy + 62;
513
+ this._retryBtn = { x: bx, y: by, w: 96, h: 30 };
514
+ ctx.fillStyle = this._hoveredBtn === 'retry' ? this._primary + 'dd' : this._primary;
515
+ this._rr(ctx, bx, by, 96, 30, this._isMat ? 15 : 8); ctx.fill();
516
+ ctx.fillStyle = '#fff'; ctx.font = '13px sans-serif'; ctx.textBaseline = 'middle';
517
+ ctx.fillText('Réessayer', cx, by + 15);
518
+ }
519
+
520
+ _drawThumbs(ctx) {
521
+ const tr = this._thumbRect;
522
+ ctx.fillStyle = this._colors.thumbsBg; ctx.fillRect(tr.x, tr.y, tr.w, tr.h);
523
+ ctx.strokeStyle = this._colors.thumbBorder; ctx.lineWidth = 1;
524
+ ctx.beginPath(); ctx.moveTo(tr.x+tr.w, tr.y); ctx.lineTo(tr.x+tr.w, tr.y+tr.h); ctx.stroke();
525
+
526
+ ctx.save(); ctx.beginPath(); ctx.rect(tr.x, tr.y, tr.w, tr.h); ctx.clip();
527
+ let ty = tr.y + 8;
528
+ for (let p = 1; p <= this.totalPages; p++) {
529
+ const img = this._thumbImages[p];
530
+ const tw = tr.w - 16;
531
+ const th = img ? Math.round(img.height * tw / img.width) : 70;
532
+ const tx = tr.x + 8;
533
+ if (ty + th > tr.y && ty < tr.y + tr.h) {
534
+ const active = p === this.currentPage;
535
+ ctx.strokeStyle = active ? this._colors.thumbActive : this._colors.thumbBorder;
536
+ ctx.lineWidth = active ? 2 : 1;
537
+ this._rr(ctx, tx, ty, tw, th, 3);
538
+ if (img) { ctx.drawImage(img, tx, ty, tw, th); ctx.stroke(); }
539
+ else { ctx.fillStyle = '#fff'; ctx.fill(); ctx.stroke(); }
540
+ ctx.fillStyle = '#666'; ctx.font = '10px sans-serif';
541
+ ctx.textAlign = 'center'; ctx.textBaseline = 'top';
542
+ ctx.fillText(p, tx + tw/2, ty + th + 2);
543
+ }
544
+ ty += th + 18;
545
+ }
546
+ ctx.restore();
895
547
  }
896
548
 
897
- // ─── Navigation ───────────────────────────────────────────────────────────────
898
-
899
- /**
900
- * Navigue vers une page spécifique
901
- * @param {number} page
902
- */
903
- goToPage(page) {
904
- if (!this._pdfDoc || page < 1 || page > this.totalPages) return;
905
- this.currentPage = page;
906
- if (this._pageInput) this._pageInput.value = page;
907
- this.scrollToPage(page);
908
- this._highlightThumbnail(page);
909
- this.onPageChange(this.currentPage, this.totalPages);
910
- }
549
+ _drawToolbar(ctx) {
550
+ const tb = this._tbRect;
551
+ ctx.fillStyle = this._primary; ctx.fillRect(tb.x, tb.y, tb.w, tb.h);
552
+ if (!this._isMat) {
553
+ ctx.strokeStyle = this._colors.toolbarBorder || '#C6C6C8'; ctx.lineWidth = 1;
554
+ ctx.beginPath(); ctx.moveTo(tb.x, tb.y+tb.h); ctx.lineTo(tb.x+tb.w, tb.y+tb.h); ctx.stroke();
555
+ }
911
556
 
912
- /**
913
- * Fait défiler jusqu'à une page
914
- * @param {number} page
915
- */
916
- scrollToPage(page) {
917
- const el = this._viewerEl && this._viewerEl.querySelector(`#pdf_page_${page}`);
918
- if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
919
- }
557
+ ctx.save(); ctx.beginPath(); ctx.rect(tb.x, tb.y, tb.w, tb.h); ctx.clip();
558
+
559
+ this._tbButtons = [];
560
+ this._pageInputRect = null;
561
+
562
+ let cx = tb.x + 12;
563
+ const my = tb.y + tb.h / 2;
564
+
565
+ // Titre
566
+ const title = typeof this.src === 'string' ? (this.src.split('/').pop() || 'Document.pdf') : 'Document.pdf';
567
+ ctx.fillStyle = 'rgba(255,255,255,0.92)';
568
+ ctx.font = `${this._isMat ? '500' : '600'} ${this._isMat ? 15 : 16}px sans-serif`;
569
+ ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
570
+ let t = title;
571
+ const availW = tb.w - 200;
572
+ while (ctx.measureText(t).width > availW && t.length > 2) t = t.slice(0, -1);
573
+ if (t !== title) t += '…';
574
+ ctx.fillText(t, cx, my);
575
+
576
+ // Navigation
577
+ if (this.totalPages > 1 && this.allowNavigation) {
578
+ cx = tb.x + tb.w - 240;
579
+ cx = this._btn(ctx, cx, my, 'prev', this._icChevron('left'), 0, this.currentPage <= 1);
580
+ ctx.fillStyle = 'rgba(255,255,255,0.88)'; ctx.font = '13px sans-serif';
581
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
582
+ ctx.fillText(`${this.currentPage}/${this.totalPages}`, cx + 20, my);
583
+ this._pageInputRect = { x: cx, y: my - 16, w: 44, h: 32 };
584
+ cx += 44;
585
+ cx = this._btn(ctx, cx, my, 'next', this._icChevron('right'), 0, this.currentPage >= this.totalPages);
586
+ cx += 8;
587
+ }
920
588
 
921
- /**
922
- * Page précédente
923
- */
924
- goToPreviousPage() {
925
- if (this.currentPage > 1) this.goToPage(this.currentPage - 1);
926
- }
589
+ // Zoom
590
+ cx = tb.x + tb.w - 120;
591
+ if (this.allowZoom) {
592
+ cx = this._btn(ctx, cx, my, 'zoomOut', '−', 20, this.scale <= this.minScale);
593
+ ctx.fillStyle = 'rgba(255,255,255,0.88)'; ctx.font = '12px sans-serif';
594
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
595
+ ctx.fillText(`${Math.round(this.scale * 100)}%`, cx + 20, my);
596
+ cx += 44;
597
+ cx = this._btn(ctx, cx, my, 'zoomIn', '+', 20, this.scale >= this.maxScale);
598
+ }
927
599
 
928
- /**
929
- * Page suivante
930
- */
931
- goToNextPage() {
932
- if (this.currentPage < this.totalPages) this.goToPage(this.currentPage + 1);
600
+ ctx.restore();
933
601
  }
934
602
 
935
- // ─── API publique ─────────────────────────────────────────────────────────────
936
-
937
- /**
938
- * Charge un nouveau document PDF
939
- * @param {string|Uint8Array} src - URL ou données binaires
940
- */
941
- load(src) {
942
- this.src = src;
943
- if (this._viewerEl) {
944
- this._pdfDoc = null;
945
- this._pageCanvases = {};
946
- this._thumbCanvases = {};
947
- if (this._thumbsEl) this._thumbsEl.innerHTML = '';
948
- this._loadPDF(src);
603
+ _btn(ctx, x, y, id, content, fontSize = 0, disabled = false) {
604
+ const bw = 32, bh = 32, bx = x, by = y - 16;
605
+ if (this._hoveredBtn === id && !disabled) {
606
+ ctx.fillStyle = 'rgba(255,255,255,0.22)';
607
+ this._rr(ctx, bx, by, bw, bh, this._isMat ? 4 : 6); ctx.fill();
949
608
  }
609
+ this._tbButtons.push({ id, x: bx, y: by, w: bw, h: bh, disabled });
610
+ ctx.globalAlpha = disabled ? 0.35 : 1;
611
+ if (typeof content === 'string' && content.length <= 2) {
612
+ ctx.fillStyle = '#fff'; ctx.font = `${fontSize || 18}px sans-serif`;
613
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
614
+ ctx.fillText(content, bx + bw/2, y);
615
+ } else {
616
+ ctx.save(); ctx.translate(bx + 7, y - 9);
617
+ ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
618
+ ctx.stroke(new Path2D(content));
619
+ ctx.restore();
620
+ }
621
+ ctx.globalAlpha = 1;
622
+ return x + bw + 4;
623
+ }
624
+
625
+ _drawSearchBar(ctx) {
626
+ const sb = this._sbRect; if (sb.h === 0) return;
627
+ ctx.fillStyle = this._isMat ? this.m3Colors.surfaceVariant : '#F2F2F7';
628
+ ctx.fillRect(sb.x, sb.y, sb.w, sb.h);
629
+ ctx.strokeStyle = this._colors.outlineVariant || '#C6C6C8'; ctx.lineWidth = 1;
630
+ ctx.beginPath(); ctx.moveTo(sb.x, sb.y+sb.h); ctx.lineTo(sb.x+sb.w, sb.y+sb.h); ctx.stroke();
631
+
632
+ const fw = sb.w - 24 - 38, fh = sb.h - 16;
633
+ ctx.fillStyle = '#fff'; this._rr(ctx, sb.x+12, sb.y+8, fw, fh, this._isMat ? 4 : 8); ctx.fill();
634
+ ctx.strokeStyle = this._colors.outline || '#C6C6C8';
635
+ this._rr(ctx, sb.x+12, sb.y+8, fw, fh, this._isMat ? 4 : 8); ctx.stroke();
636
+ ctx.fillStyle = this._searchQuery ? '#000' : '#999';
637
+ ctx.font = '13px sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
638
+ ctx.fillText(this._searchQuery || 'Rechercher...', sb.x+20, sb.y+sb.h/2);
639
+
640
+ const clx = sb.x + sb.w - 34;
641
+ this._searchCloseRect = { x: clx, y: sb.y, w: 34, h: sb.h };
642
+ ctx.fillStyle = this._hoveredBtn === 'searchClose' ? '#444' : '#666';
643
+ ctx.font = '15px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
644
+ ctx.fillText('✕', clx + 17, sb.y + sb.h/2);
645
+ }
646
+
647
+ // ─── Actions ─────────────────────────────────────────────────────────────────
648
+
649
+ _handleBtn(id) {
650
+ switch (id) {
651
+ case 'prev': this.goToPreviousPage(); break;
652
+ case 'next': this.goToNextPage(); break;
653
+ case 'zoomIn': this._setScale(this.scale + 0.25); break;
654
+ case 'zoomOut': this._setScale(this.scale - 0.25); break;
655
+ case 'zoomFit': this._fitToWidth(); break;
656
+ case 'rotate': this._rotate(); break;
657
+ case 'search': this._toggleSearch(); break;
658
+ case 'download': this._download(); break;
659
+ case 'print': this._print(); break;
660
+ }
661
+ this.markDirty();
950
662
  }
951
663
 
952
- /**
953
- * Zoom à un niveau précis
954
- * @param {number} scale - Facteur de zoom (ex: 1.5 = 150%)
955
- */
956
- setScale(scale) {
957
- this._setScale(scale);
664
+ _setScale(s) {
665
+ this.scale = Math.max(this.minScale, Math.min(this.maxScale, Math.round(s * 100) / 100));
666
+ this.onScaleChange(this.scale);
667
+ this._reRenderAll();
958
668
  }
959
669
 
960
- /**
961
- * Retourne les métadonnées du document
962
- * @returns {Promise<Object>}
963
- */
964
- async getMetadata() {
965
- if (!this._pdfDoc) return null;
966
- return this._pdfDoc.getMetadata();
670
+ async _fitToWidth() {
671
+ if (!this._pdfDoc) return;
672
+ const vp = (await this._pdfDoc.getPage(1)).getViewport({ scale: 1 });
673
+ this._setScale((this._viewerRect.w - 32) / vp.width);
967
674
  }
968
675
 
969
- // ─── SVG Icons ────────────────────────────────────────────────────────────────
676
+ _rotate() { this.rotation = (this.rotation + 90) % 360; this._reRenderAll(); }
970
677
 
971
- _svgChevron(dir) {
972
- const pts = dir === 'left' ? '15 18 9 12 15 6' : '9 18 15 12 9 6';
973
- return `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round">
974
- <polyline points="${pts}"/>
975
- </svg>`;
678
+ _download() {
679
+ if (typeof this.src !== 'string') return;
680
+ const a = document.createElement('a');
681
+ a.href = this.src; a.download = this.src.split('/').pop() || 'document.pdf'; a.click();
976
682
  }
977
683
 
978
- _svgFit() {
979
- return `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round">
980
- <polyline points="15,3 21,3 21,9"/><polyline points="9,21 3,21 3,15"/>
981
- <line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
982
- </svg>`;
684
+ _print() {
685
+ if (typeof this.src !== 'string') return;
686
+ const w = window.open(this.src);
687
+ if (w) w.addEventListener('load', () => w.print());
983
688
  }
984
689
 
985
- _svgRotate() {
986
- return `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round">
987
- <path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 .49-4.46"/>
988
- </svg>`;
989
- }
690
+ _toggleSearch() { this._searchOpen = !this._searchOpen; this._computeMaxScroll(); this.markDirty(); }
990
691
 
991
- _svgSearch() {
992
- return `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round">
993
- <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
994
- </svg>`;
692
+ _promptPage() {
693
+ const v = prompt(`Aller à la page (1–${this.totalPages}) :`, this.currentPage);
694
+ if (v !== null) { const p = parseInt(v); if (p >= 1 && p <= this.totalPages) this.goToPage(p); }
995
695
  }
996
696
 
997
- _svgDownload() {
998
- return `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round">
999
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
1000
- <polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
1001
- </svg>`;
1002
- }
697
+ // ─── Navigation publique ─────────────────────────────────────────────────────
1003
698
 
1004
- _svgPrint() {
1005
- return `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round">
1006
- <polyline points="6 9 6 2 18 2 18 9"/>
1007
- <path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/>
1008
- <rect x="6" y="14" width="12" height="8"/>
1009
- </svg>`;
699
+ goToPage(page) {
700
+ if (!this._pdfDoc || page < 1 || page > this.totalPages) return;
701
+ this.currentPage = page;
702
+ this._scrollY = Math.max(0, Math.min(this._maxScrollY, this._pageScrollOffset(page) - 8));
703
+ this.onPageChange(page, this.totalPages);
704
+ this.markDirty();
1010
705
  }
1011
706
 
1012
- _svgFullscreen() {
1013
- return `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round">
1014
- <polyline points="15 3 21 3 21 9"/><polyline points="9 3 3 3 3 9"/>
1015
- <polyline points="21 15 21 21 15 21"/><polyline points="3 15 3 21 9 21"/>
1016
- </svg>`;
1017
- }
707
+ goToPreviousPage() { if (this.currentPage > 1) this.goToPage(this.currentPage - 1); }
708
+ goToNextPage() { if (this.currentPage < this.totalPages) this.goToPage(this.currentPage + 1); }
1018
709
 
1019
- // ─── Rendu Canvas (shell) ─────────────────────────────────────────────────────
710
+ // ─── API publique ─────────────────────────────────────────────────────────────
1020
711
 
1021
- /**
1022
- * Rendu Canvas : monte l'overlay DOM et synchronise la position
1023
- * @param {CanvasRenderingContext2D} ctx
1024
- */
1025
- draw(ctx) {
1026
- ctx.save();
712
+ load(src) {
713
+ this.src = src; this._pdfDoc = null;
714
+ this._pageImages = {}; this._thumbImages = {}; this._scrollY = 0;
715
+ this._loadPDF(src);
716
+ }
1027
717
 
1028
- if (!this._mounted) this._mount();
718
+ setScale(s) { this._setScale(s); }
719
+ async getMetadata() { return this._pdfDoc ? this._pdfDoc.getMetadata() : null; }
1029
720
 
1030
- // Synchronise la position
1031
- if (this._containerEl) {
1032
- const canvas = this.framework.canvas;
1033
- const rect = canvas.getBoundingClientRect();
1034
- this._containerEl.style.left = `${rect.left + this.x}px`;
1035
- this._containerEl.style.top = `${rect.top + this.y - (this.framework.scrollOffset || 0)}px`;
1036
- this._containerEl.style.width = `${this.width}px`;
1037
- this._containerEl.style.height = `${this.height}px`;
1038
- }
721
+ // ─── Icônes ──────────────────────────────────────────────────────────────────
1039
722
 
1040
- // Fond Canvas (zone réservée) invisible car l'overlay DOM le couvre
1041
- ctx.fillStyle = this.backgroundColor || this._colors.viewerBg;
1042
- ctx.fillRect(this.x, this.y, this.width, this.height);
723
+ _icChevron(d) { return d === 'left' ? 'M12 3 L5 9 L12 15' : 'M6 3 L13 9 L6 15'; }
1043
724
 
1044
- ctx.restore();
725
+ // ─── roundRect ───────────────────────────────────────────────────────────────
726
+
727
+ _rr(ctx, x, y, w, h, r) {
728
+ r = Math.min(r, w/2, h/2);
729
+ ctx.beginPath();
730
+ ctx.moveTo(x+r, y); ctx.lineTo(x+w-r, y);
731
+ ctx.quadraticCurveTo(x+w, y, x+w, y+r);
732
+ ctx.lineTo(x+w, y+h-r);
733
+ ctx.quadraticCurveTo(x+w, y+h, x+w-r, y+h);
734
+ ctx.lineTo(x+r, y+h);
735
+ ctx.quadraticCurveTo(x, y+h, x, y+h-r);
736
+ ctx.lineTo(x, y+r);
737
+ ctx.quadraticCurveTo(x, y, x+r, y);
738
+ ctx.closePath();
1045
739
  }
1046
740
 
1047
- /**
1048
- * Vérifie si un point est dans le composant
1049
- */
1050
741
  isPointInside(x, y) {
1051
742
  return x >= this.x && x <= this.x + this.width &&
1052
743
  y >= this.y && y <= this.y + this.height;
1053
744
  }
1054
745
 
1055
- /**
1056
- * Nettoie les ressources
1057
- */
1058
746
  destroy() {
1059
- if (this._containerEl && this._containerEl.parentNode) {
1060
- this._containerEl.parentNode.removeChild(this._containerEl);
1061
- }
1062
- this._pdfDoc = null;
1063
- this._mounted = false;
1064
- this._containerEl = null;
1065
- super.destroy && super.destroy();
747
+ if (this._spinRAF) cancelAnimationFrame(this._spinRAF);
748
+ this._pdfDoc = null;
749
+ super.destroy?.();
1066
750
  }
1067
751
  }
1068
752