canvasframework 0.5.63 → 0.5.64

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.
Files changed (2) hide show
  1. package/components/PDFViewer.js +1039 -890
  2. package/package.json +1 -1
@@ -1,1068 +1,1217 @@
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.
4
+ * Lecteur PDF entièrement dessiné sur Canvas.
5
+ * Gère ses propres événements natifs (click, wheel, mouse, touch)
6
+ * directement sur le canvas — aucune dépendance au framework pour les interactions.
9
7
  *
10
8
  * @class
11
9
  * @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
20
10
  */
21
11
  class PDFViewer extends Component {
22
12
 
23
13
  /**
24
14
  * @param {CanvasFramework} framework
25
15
  * @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)
16
+ * @param {string|Uint8Array} [options.src]
17
+ * @param {number} [options.initialPage=1]
18
+ * @param {number} [options.initialScale=1.0]
19
+ * @param {boolean} [options.showToolbar=true]
20
+ * @param {boolean} [options.showThumbnails=false]
21
+ * @param {boolean} [options.allowZoom=true] - Boutons zoom
22
+ * @param {boolean} [options.allowNavigation=true] - Prev/next (masqué si 1 page)
23
+ * @param {boolean} [options.allowDownload=true]
24
+ * @param {boolean} [options.allowPrint=true]
25
+ * @param {boolean} [options.allowRotate=true]
26
+ * @param {boolean} [options.allowSearch=true]
27
+ * @param {number} [options.minScale=0.25]
28
+ * @param {number} [options.maxScale=5.0]
29
+ * @param {string} [options.backgroundColor]
30
+ * @param {string} [options.primaryColor]
31
+ * @param {string} [options.pageBackground='#FFFFFF']
32
+ * @param {Function} [options.onPageChange]
33
+ * @param {Function} [options.onScaleChange]
34
+ * @param {Function} [options.onLoad]
35
+ * @param {Function} [options.onError]
45
36
  */
46
37
  constructor(framework, options = {}) {
47
38
  super(framework, options);
48
39
 
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
40
+ this.platform = framework.platform;
41
+ this.src = options.src || null;
42
+ this.currentPage = options.initialPage || 1;
43
+ this.totalPages = 0;
44
+ this.scale = options.initialScale || 1.0;
45
+ this.rotation = 0;
46
+ this.loading = false;
47
+ this.error = null;
48
+
49
+ this.showToolbar = options.showToolbar !== false;
50
+ this.showThumbnails = options.showThumbnails || false;
51
+ this.allowZoom = options.allowZoom !== false;
52
+ this.allowNavigation = options.allowNavigation !== false;
53
+ this.allowDownload = options.allowDownload !== false;
54
+ this.allowPrint = options.allowPrint !== false;
55
+ this.allowRotate = options.allowRotate !== false;
56
+ this.allowSearch = options.allowSearch !== false;
57
+ this.minScale = options.minScale || 0.25;
58
+ this.maxScale = options.maxScale || 5.0;
59
+ this.backgroundColor = options.backgroundColor || null;
60
+ this.primaryColor = options.primaryColor || null;
61
+ this.pageBackground = options.pageBackground || '#FFFFFF';
62
+
71
63
  this.onPageChange = options.onPageChange || (() => {});
72
64
  this.onScaleChange = options.onScaleChange || (() => {});
73
65
  this.onLoad = options.onLoad || (() => {});
74
66
  this.onErrorCb = options.onError || (() => {});
75
67
 
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;
68
+ // Layout
69
+ this._toolbarHeight = this.showToolbar ? 52 : 0;
70
+ this._thumbsWidth = 110; // toujours réservé, affiché si totalPages > 1
87
71
  this._searchOpen = false;
88
72
  this._searchQuery = '';
89
- this._fullscreen = false;
90
73
 
91
- this._toolbarHeight = this.showToolbar ? 52 : 0;
92
- this._thumbsWidth = this.showThumbnails ? 120 : 0;
74
+ // PDF state
75
+ this._pdfDoc = null;
76
+ this._pageImages = {};
77
+ this._thumbImages = {};
78
+
79
+ // Viewer scroll
80
+ this._scrollY = 0;
81
+ this._maxScrollY = 0;
82
+
83
+ // Drag scroll (mouse)
84
+ this._dragging = false;
85
+ this._dragStartY = 0;
86
+ this._dragStartScroll = 0;
87
+
88
+ // Touch scroll
89
+ this._touchStartY = 0;
90
+ this._touchLastY = 0;
91
+ this._touchVelocity = 0;
92
+ this._momentumRAF = null;
93
+
94
+ // Toolbar hit areas (rebuilt each draw)
95
+ this._tbButtons = [];
96
+ this._pageInputRect = null;
97
+ this._retryBtn = null;
98
+ this._searchCloseRect= null;
99
+ this._hoveredBtn = null;
100
+
101
+ // Spinner
102
+ this._spinAngle = 0;
103
+ this._spinRAF = null;
104
+
105
+ // Events
106
+ this._eventsRegistered = false;
107
+ this._boundHandlers = {};
108
+
109
+ // Mouse position tracking
110
+ this._lastMouseX = 0;
111
+ this._lastMouseY = 0;
93
112
 
94
113
  // Couleurs Material You 3
95
114
  this.m3Colors = {
96
115
  primary: '#6750A4',
97
116
  onPrimary: '#FFFFFF',
98
- surface: '#FFFBFE',
99
117
  surfaceVariant: '#E7E0EC',
100
118
  onSurface: '#1C1B1F',
101
119
  onSurfaceVariant: '#49454F',
102
120
  outline: '#79747E',
103
121
  outlineVariant: '#CAC4D0',
104
122
  error: '#BA1A1A',
105
- shadow: '#00000040',
106
- toolbarBg: '#6750A4',
107
- toolbarText: '#FFFFFF',
108
123
  viewerBg: '#F7F2FA',
109
124
  thumbsBg: '#EFE9F4',
110
125
  thumbBorder: '#CAC4D0',
111
126
  thumbActive: '#6750A4',
112
- pageBox: '#FFFFFF',
113
- pageShadow: '#00000026',
127
+ pageShadow: 'rgba(0,0,0,0.15)',
114
128
  };
115
129
 
116
130
  // Couleurs Cupertino
117
131
  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',
132
+ primary: '#007AFF',
133
+ onPrimary: '#FFFFFF',
134
+ error: '#FF3B30',
135
+ toolbarBorder: '#C6C6C8',
136
+ viewerBg: '#D1D1D6',
137
+ thumbsBg: '#F2F2F7',
138
+ thumbBorder: '#C6C6C8',
139
+ thumbActive: '#007AFF',
140
+ pageShadow: 'rgba(0,0,0,0.15)',
141
+ onSurfaceVariant: '#8E8E93',
142
+ outlineVariant: '#C6C6C8',
143
+ outline: '#C6C6C8',
131
144
  };
132
- }
133
145
 
134
- get _colors() {
135
- return this.platform === 'material' ? this.m3Colors : this.cupertinoColors;
146
+ if (this.src) this._loadPDF(this.src);
147
+ this._startSpinner();
136
148
  }
137
149
 
138
- get _primary() {
139
- return this.primaryColor || this._colors.primary;
150
+ // ─── Couleurs & géométrie ────────────────────────────────────────────────────
151
+
152
+ get _colors() { return this.platform === 'material' ? this.m3Colors : this.cupertinoColors; }
153
+ get _primary() { return this.primaryColor || this._colors.primary; }
154
+ get _isMat() { return this.platform === 'material'; }
155
+
156
+ get _tbRect() {
157
+ return { x: this.x, y: this.y, w: this.width, h: this._toolbarHeight };
158
+ }
159
+ get _sbRect() {
160
+ return { x: this.x, y: this.y + this._toolbarHeight, w: this.width, h: this._searchOpen ? 44 : 0 };
161
+ }
162
+ get _activeThumbsWidth() { return this.totalPages > 1 ? this._thumbsWidth : 0; }
163
+ get _viewerRect() {
164
+ const top = this._toolbarHeight + this._sbRect.h;
165
+ return { x: this.x + this._activeThumbsWidth, y: this.y + top, w: this.width - this._activeThumbsWidth, h: this.height - top };
166
+ }
167
+ get _thumbRect() {
168
+ const top = this._toolbarHeight + this._sbRect.h;
169
+ return { x: this.x, y: this.y + top, w: this._thumbsWidth, h: this.height - top };
140
170
  }
141
171
 
142
- // ─── Chargement PDF.js ────────────────────────────────────────────────────────
172
+ // ─── Conversion événements → coordonnées canvas ──────────────────────────────
173
+
174
+ _evtToXY(e) {
175
+ const canvas = this.framework.canvas;
176
+ const rect = canvas.getBoundingClientRect();
177
+ // Ratio CSS vs canvas réel
178
+ const sx = canvas.width / rect.width;
179
+ const sy = canvas.height / rect.height;
180
+ let cx, cy;
181
+ if (e.touches && e.touches.length > 0) {
182
+ cx = e.touches[0].clientX; cy = e.touches[0].clientY;
183
+ } else if (e.changedTouches && e.changedTouches.length > 0) {
184
+ cx = e.changedTouches[0].clientX; cy = e.changedTouches[0].clientY;
185
+ } else {
186
+ cx = e.clientX; cy = e.clientY;
187
+ }
188
+ // On ajoute le scrollOffset du framework pour être en coordonnées "monde"
189
+ const worldY = (cy - rect.top) * sy + (this.framework.scrollOffset || 0);
190
+ return { x: (cx - rect.left) * sx, y: worldY };
191
+ }
143
192
 
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;
193
+ _inSelf(x, y) {
194
+ return x >= this.x && x <= this.x + this.width &&
195
+ y >= this.y && y <= this.y + this.height;
196
+ }
151
197
 
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
- });
198
+ _inViewer(x, y) {
199
+ const v = this._viewerRect;
200
+ return x >= v.x && x <= v.x + v.w && y >= v.y && y <= v.y + v.h;
163
201
  }
164
202
 
165
- // ─── Montage DOM ─────────────────────────────────────────────────────────────
203
+ // ─── Enregistrement des événements natifs (one-shot) ─────────────────────────
204
+
205
+ _registerEvents() {
206
+ if (this._eventsRegistered) return;
207
+ this._eventsRegistered = true;
208
+ const canvas = this.framework.canvas;
209
+
210
+ // ── Mouse ──
211
+ const onMouseDown = (e) => {
212
+ const { x, y } = this._evtToXY(e);
213
+ if (!this._inSelf(x, y)) return;
214
+
215
+ // Vérifier si on clique sur la toolbar ou les miniatures
216
+ const inToolbar = this.showToolbar && y >= this._tbRect.y && y <= this._tbRect.y + this._tbRect.h;
217
+ const inThumbs = this.totalPages > 1 && x >= this._thumbRect.x && x <= this._thumbRect.x + this._thumbRect.w;
218
+
219
+ if (inToolbar || inThumbs) {
220
+ // Ne pas démarrer le drag pour les interactions toolbar/miniatures
221
+ return;
222
+ }
223
+
224
+ if (this._inViewer(x, y)) {
225
+ this._dragging = true;
226
+ this._dragStartY = y;
227
+ this._dragStartScroll = this._scrollY;
228
+ if (this._momentumRAF) {
229
+ cancelAnimationFrame(this._momentumRAF);
230
+ this._momentumRAF = null;
231
+ }
232
+ }
233
+ };
166
234
 
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
- `;
235
+ const onMouseMove = (e) => {
236
+ const { x, y } = this._evtToXY(e);
237
+ this._lastMouseX = x;
238
+ this._lastMouseY = y;
239
+
240
+ // Hover - toujours vérifier le survol
241
+ if (this._inSelf(x, y)) {
242
+ this._checkHover(x, y);
243
+ } else if (this._hoveredBtn !== null) {
244
+ this._hoveredBtn = null;
245
+ this._redraw();
246
+ }
247
+
248
+ // Drag
249
+ if (this._dragging) {
250
+ this._scrollY = Math.max(0, Math.min(this._maxScrollY, this._dragStartScroll + (this._dragStartY - y)));
251
+ this._syncPage();
252
+ this._redraw();
253
+ }
254
+ };
198
255
 
199
- // Toolbar
200
- if (this.showToolbar) {
201
- this._toolbarEl = this._buildToolbar();
202
- this._containerEl.appendChild(this._toolbarEl);
203
- }
256
+ const onMouseUp = () => {
257
+ this._dragging = false;
258
+ };
204
259
 
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
- }
260
+ const onMouseLeave = () => {
261
+ this._dragging = false;
262
+ this._hoveredBtn = null;
263
+ this._redraw();
264
+ };
222
265
 
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) => {
266
+ // ── Click ──
267
+ const onClick = (e) => {
268
+ const { x, y } = this._evtToXY(e);
269
+ if (!this._inSelf(x, y)) return;
270
+ this._handleClick(x, y);
271
+ };
272
+
273
+ // ── Wheel ──
274
+ const onWheel = (e) => {
275
+ const { x, y } = this._evtToXY(e);
276
+ if (!this._inSelf(x, y)) return;
277
+
278
+ e.preventDefault();
279
+ e.stopPropagation();
280
+
243
281
  if (e.ctrlKey || e.metaKey) {
244
- e.preventDefault();
282
+ // Zoom avec Ctrl+molette
245
283
  const delta = e.deltaY > 0 ? -0.1 : 0.1;
246
284
  this._setScale(this.scale + delta);
285
+ } else {
286
+ // Scroll normal
287
+ if (this._inViewer(x, y)) {
288
+ this._scrollY = Math.max(0, Math.min(this._maxScrollY, this._scrollY + e.deltaY));
289
+ this._syncPage();
290
+ this._redraw();
291
+ }
292
+ }
293
+ };
294
+
295
+ // ── Touch ──
296
+ const onTouchStart = (e) => {
297
+ const { x, y } = this._evtToXY(e);
298
+ if (!this._inSelf(x, y)) return;
299
+
300
+ // Vérifier si on touche la toolbar ou les miniatures
301
+ const inToolbar = this.showToolbar && y >= this._tbRect.y && y <= this._tbRect.y + this._tbRect.h;
302
+ const inThumbs = this.totalPages > 1 && x >= this._thumbRect.x && x <= this._thumbRect.x + this._thumbRect.w;
303
+
304
+ if (inToolbar || inThumbs) {
305
+ return;
306
+ }
307
+
308
+ if (this._inViewer(x, y)) {
309
+ this._dragging = true;
310
+ this._touchStartY = y;
311
+ this._touchLastY = y;
312
+ this._touchVelocity = 0;
313
+ this._dragStartScroll = this._scrollY;
314
+ if (this._momentumRAF) {
315
+ cancelAnimationFrame(this._momentumRAF);
316
+ this._momentumRAF = null;
317
+ }
318
+ e.preventDefault();
247
319
  }
248
- }, { passive: false });
320
+ };
249
321
 
250
- body.appendChild(this._viewerEl);
251
- this._containerEl.appendChild(body);
252
- document.body.appendChild(this._containerEl);
322
+ const onTouchMove = (e) => {
323
+ if (!this._dragging) return;
324
+ const { y } = this._evtToXY(e);
325
+ this._touchVelocity = this._touchLastY - y;
326
+ this._touchLastY = y;
327
+ this._scrollY = Math.max(0, Math.min(this._maxScrollY, this._dragStartScroll + (this._touchStartY - y)));
328
+ this._syncPage();
329
+ this._redraw();
330
+ e.preventDefault();
331
+ };
253
332
 
254
- // Charger le PDF si src fournie
255
- if (this.src) this._loadPDF(this.src);
256
- else this._renderEmptyState();
333
+ const onTouchEnd = (e) => {
334
+ if (!this._dragging) return;
335
+ this._dragging = false;
336
+
337
+ // Vérifier si c'était un tap (pas de mouvement)
338
+ if (Math.abs(this._touchVelocity) < 0.5) {
339
+ const { x, y } = this._evtToXY(e);
340
+ this._handleClick(x, y);
341
+ return;
342
+ }
343
+
344
+ // Momentum
345
+ let v = this._touchVelocity;
346
+ const momentum = () => {
347
+ if (Math.abs(v) < 0.5) return;
348
+ this._scrollY = Math.max(0, Math.min(this._maxScrollY, this._scrollY + v));
349
+ v *= 0.92;
350
+ this._syncPage();
351
+ this._redraw();
352
+ this._momentumRAF = requestAnimationFrame(momentum);
353
+ };
354
+ this._momentumRAF = requestAnimationFrame(momentum);
355
+ };
356
+
357
+ // Ajout des écouteurs
358
+ canvas.addEventListener('mousedown', onMouseDown);
359
+ canvas.addEventListener('mousemove', onMouseMove);
360
+ canvas.addEventListener('mouseup', onMouseUp);
361
+ canvas.addEventListener('mouseleave', onMouseLeave);
362
+ canvas.addEventListener('click', onClick);
363
+ canvas.addEventListener('wheel', onWheel, { passive: false });
364
+ canvas.addEventListener('touchstart', onTouchStart, { passive: false });
365
+ canvas.addEventListener('touchmove', onTouchMove, { passive: false });
366
+ canvas.addEventListener('touchend', onTouchEnd);
367
+ canvas.addEventListener('touchcancel', onTouchEnd);
368
+
369
+ this._boundHandlers = { onMouseDown, onMouseMove, onMouseUp, onMouseLeave, onClick, onWheel, onTouchStart, onTouchMove, onTouchEnd };
370
+ }
371
+
372
+ _checkHover(x, y) {
373
+ let hover = null;
374
+
375
+ // Vérifier les boutons de la toolbar
376
+ for (const b of this._tbButtons) {
377
+ if (!b.disabled && x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h) {
378
+ hover = b.id;
379
+ break;
380
+ }
381
+ }
382
+
383
+ // Vérifier le bouton de fermeture de recherche
384
+ if (this._searchCloseRect && !hover) {
385
+ const r = this._searchCloseRect;
386
+ if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) {
387
+ hover = 'searchClose';
388
+ }
389
+ }
390
+
391
+ // Vérifier le bouton de réessai
392
+ if (this._retryBtn && !hover) {
393
+ const r = this._retryBtn;
394
+ if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) {
395
+ hover = 'retry';
396
+ }
397
+ }
398
+
399
+ if (hover !== this._hoveredBtn) {
400
+ this._hoveredBtn = hover;
401
+ this._redraw();
402
+ }
257
403
  }
258
404
 
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;
405
+ _redraw() {
406
+ if (this.framework && this.framework.redraw) this.framework.redraw();
407
+ }
408
+
409
+ // ─── Spinner ─────────────────────────────────────────────────────────────────
410
+
411
+ _startSpinner() {
412
+ const tick = () => {
413
+ if (this.loading) {
414
+ this._spinAngle = (this._spinAngle + 0.08) % (Math.PI * 2);
415
+ this._redraw();
416
+ }
417
+ this._spinRAF = requestAnimationFrame(tick);
418
+ };
419
+ this._spinRAF = requestAnimationFrame(tick);
420
+ }
421
+
422
+ // ─── Chargement PDF.js ───────────────────────────────────────────────────────
423
+
424
+ async _loadPDFJS() {
425
+ if (window.pdfjsLib) return window.pdfjsLib;
426
+ return new Promise((resolve, reject) => {
427
+ const s = document.createElement('script');
428
+ s.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
429
+ s.onload = () => {
430
+ window.pdfjsLib.GlobalWorkerOptions.workerSrc =
431
+ 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
432
+ resolve(window.pdfjsLib);
433
+ };
434
+ s.onerror = () => reject(new Error('Impossible de charger PDF.js'));
435
+ document.head.appendChild(s);
327
436
  });
437
+ }
328
438
 
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());
439
+ async _loadPDF(src) {
440
+ this.loading = true;
441
+ this.error = null;
442
+ this._pageImages = {};
443
+ this._thumbImages = {};
444
+ this._scrollY = 0;
445
+ this._redraw();
446
+ try {
447
+ const lib = await this._loadPDFJS();
448
+ const task = lib.getDocument(typeof src === 'string' ? src : { data: src });
449
+ this._pdfDoc = await task.promise;
450
+ this.totalPages = this._pdfDoc.numPages;
451
+
452
+ // Charger les pages de manière asynchrone sans bloquer
453
+ const loadPromises = [];
454
+ for (let p = 1; p <= this.totalPages; p++) {
455
+ loadPromises.push(this._renderPage(p));
456
+ loadPromises.push(this._renderThumb(p));
457
+ }
458
+
459
+ await Promise.all(loadPromises);
460
+
461
+ this.loading = false;
462
+ this._computeMaxScroll();
463
+ this.onLoad(this.totalPages);
464
+ this._redraw();
465
+ } catch (err) {
466
+ this.loading = false;
467
+ this.error = err.message || 'Erreur de chargement';
468
+ this.onErrorCb(this.error);
469
+ this._redraw();
379
470
  }
471
+ }
380
472
 
381
- if (this.allowRotate) {
382
- const btnRotate = this._makeToolbarBtn(this._svgRotate(), 'Rotation 90°');
383
- btnRotate.addEventListener('click', () => this._rotate());
384
- toolbar.appendChild(btnRotate);
473
+ async _renderPage(p) {
474
+ try {
475
+ const page = await this._pdfDoc.getPage(p);
476
+ const vp = page.getViewport({ scale: this.scale, rotation: this.rotation });
477
+ const off = new OffscreenCanvas(Math.ceil(vp.width), Math.ceil(vp.height));
478
+ await page.render({ canvasContext: off.getContext('2d'), viewport: vp }).promise;
479
+ this._pageImages[p] = await createImageBitmap(off);
480
+ } catch (err) {
481
+ console.error(`Erreur rendu page ${p}:`, err);
385
482
  }
483
+ }
386
484
 
387
- if (this.allowSearch) {
388
- const btnSearch = this._makeToolbarBtn(this._svgSearch(), 'Rechercher');
389
- btnSearch.addEventListener('click', () => this._toggleSearch());
390
- toolbar.appendChild(btnSearch);
485
+ async _renderThumb(p) {
486
+ try {
487
+ const page = await this._pdfDoc.getPage(p);
488
+ const vp = page.getViewport({ scale: 0.18, rotation: this.rotation });
489
+ const off = new OffscreenCanvas(Math.ceil(vp.width), Math.ceil(vp.height));
490
+ await page.render({ canvasContext: off.getContext('2d'), viewport: vp }).promise;
491
+ this._thumbImages[p] = await createImageBitmap(off);
492
+ } catch (err) {
493
+ console.error(`Erreur rendu miniature ${p}:`, err);
391
494
  }
495
+ }
392
496
 
393
- if (this.allowDownload) {
394
- const btnDl = this._makeToolbarBtn(this._svgDownload(), 'Télécharger');
395
- btnDl.addEventListener('click', () => this._download());
396
- toolbar.appendChild(btnDl);
497
+ async _reRenderAll() {
498
+ if (!this._pdfDoc) return;
499
+ this._pageImages = {};
500
+ this._thumbImages = {};
501
+ this.loading = true;
502
+ this._redraw();
503
+
504
+ const loadPromises = [];
505
+ for (let p = 1; p <= this.totalPages; p++) {
506
+ loadPromises.push(this._renderPage(p));
507
+ loadPromises.push(this._renderThumb(p));
397
508
  }
509
+
510
+ await Promise.all(loadPromises);
511
+
512
+ this.loading = false;
513
+ this._computeMaxScroll();
514
+ this._scrollY = Math.min(this._scrollY, this._maxScrollY);
515
+ this._redraw();
516
+ }
398
517
 
399
- if (this.allowPrint) {
400
- const btnPrint = this._makeToolbarBtn(this._svgPrint(), 'Imprimer');
401
- btnPrint.addEventListener('click', () => this._print());
402
- toolbar.appendChild(btnPrint);
403
- }
518
+ // ─── Layout & scroll ─────────────────────────────────────────────────────────
404
519
 
405
- if (this.allowFullscreen) {
406
- const btnFs = this._makeToolbarBtn(this._svgFullscreen(), 'Plein écran');
407
- btnFs.addEventListener('click', () => this._toggleFullscreen());
408
- toolbar.appendChild(btnFs);
520
+ _computeMaxScroll() {
521
+ const vr = this._viewerRect;
522
+ let total = 16;
523
+ for (let p = 1; p <= this.totalPages; p++) {
524
+ total += (this._pageImages[p] ? this._pageImages[p].height : 200) + 16;
409
525
  }
410
-
411
- return toolbar;
526
+ this._maxScrollY = Math.max(0, total - vr.h);
412
527
  }
413
528
 
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;
529
+ _pageScrollOffset(pageNum) {
530
+ let y = 16;
531
+ for (let p = 1; p < pageNum; p++) {
532
+ y += (this._pageImages[p] ? this._pageImages[p].height : 200) + 16;
533
+ }
534
+ return y;
436
535
  }
437
536
 
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;
537
+ _syncPage() {
538
+ if (!this.totalPages) return;
539
+ const mid = this._scrollY + this._viewerRect.h / 2;
540
+ let y = 16;
541
+ for (let p = 1; p <= this.totalPages; p++) {
542
+ const h = this._pageImages[p] ? this._pageImages[p].height : 200;
543
+ if (mid <= y + h || p === this.totalPages) {
544
+ if (p !== this.currentPage) {
545
+ this.currentPage = p;
546
+ this.onPageChange(p, this.totalPages);
547
+ }
548
+ return;
549
+ }
550
+ y += h + 16;
551
+ }
442
552
  }
443
553
 
444
- // ─── Barre de recherche ───────────────────────────────────────────────────────
554
+ // ─── Dessin principal ────────────────────────────────────────────────────────
445
555
 
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;
484
- }
556
+ draw(ctx) {
557
+ this._registerEvents(); // one-shot
485
558
 
486
- _toggleSearch() {
487
- this._searchOpen = !this._searchOpen;
488
- this._searchBarEl.style.display = this._searchOpen ? 'flex' : 'none';
489
- if (this._searchOpen) this._searchInput.focus();
490
- }
559
+ ctx.save();
560
+ // Clip global
561
+ ctx.beginPath();
562
+ this._rr(ctx, this.x, this.y, this.width, this.height, this._isMat ? 4 : 12);
563
+ ctx.clip();
564
+
565
+ // Fond
566
+ ctx.fillStyle = this.backgroundColor || this._colors.viewerBg;
567
+ ctx.fillRect(this.x, this.y, this.width, this.height);
568
+
569
+ if (this.totalPages > 1) this._drawThumbs(ctx);
570
+ this._drawViewer(ctx);
571
+ if (this.showToolbar) this._drawToolbar(ctx);
572
+ if (this._searchOpen) this._drawSearchBar(ctx);
573
+
574
+ // Bordure
575
+ ctx.strokeStyle = this._isMat ? 'rgba(0,0,0,0.12)' : (this._colors.toolbarBorder || '#C6C6C8');
576
+ ctx.lineWidth = 1;
577
+ ctx.beginPath();
578
+ this._rr(ctx, this.x, this.y, this.width, this.height, this._isMat ? 4 : 12);
579
+ ctx.stroke();
491
580
 
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;
581
+ ctx.restore();
582
+
583
+ // Restaurer le hover pour le prochain frame
584
+ if (this._hoveredBtn && this._inSelf(this._lastMouseX, this._lastMouseY)) {
585
+ // Le hover sera redessiné au prochain move
586
+ }
513
587
  }
514
588
 
515
- // ─── Chargement PDF ───────────────────────────────────────────────────────────
589
+ // ─── Zone viewer ─────────────────────────────────────────────────────────────
516
590
 
517
- /**
518
- * Charge et affiche un PDF
519
- * @param {string|Uint8Array} src
520
- */
521
- async _loadPDF(src) {
522
- this.loading = true;
523
- this.error = null;
524
- this._renderLoadingState();
591
+ _drawViewer(ctx) {
592
+ const vr = this._viewerRect;
593
+ ctx.save();
594
+ ctx.beginPath(); ctx.rect(vr.x, vr.y, vr.w, vr.h); ctx.clip();
595
+ ctx.fillStyle = this._colors.viewerBg;
596
+ ctx.fillRect(vr.x, vr.y, vr.w, vr.h);
525
597
 
526
- 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
- }
598
+ const hasImages = Object.keys(this._pageImages).length > 0;
599
+ if (this.loading && !hasImages) this._drawLoading(ctx, vr);
600
+ else if (this.error && !hasImages) this._drawError(ctx, vr);
601
+ else if (!this.totalPages && !this.loading) this._drawEmpty(ctx, vr);
602
+ else this._drawPages(ctx, vr);
543
603
 
544
- // Vide la visionneuse
545
- this._viewerEl.innerHTML = '';
604
+ ctx.restore();
605
+ }
546
606
 
547
- // Rend toutes les pages
548
- for (let p = 1; p <= this.totalPages; p++) {
549
- await this._renderPage(p);
550
- if (this.showThumbnails) await this._renderThumbnail(p);
551
- }
607
+ _drawPages(ctx, vr) {
608
+ const pad = 16;
609
+ let y = vr.y + pad - this._scrollY;
552
610
 
553
- // Aller à la page initiale
554
- this.scrollToPage(this.currentPage);
555
- this.onLoad(this.totalPages);
611
+ for (let p = 1; p <= this.totalPages; p++) {
612
+ const img = this._pageImages[p];
613
+ const ph = img ? img.height : 200;
614
+ // Largeur : on respecte la vraie largeur de la page rendue, mais on ne dépasse pas le viewer
615
+ const pw = img ? Math.min(img.width, vr.w - pad * 2) : vr.w - pad * 2;
616
+ const px = vr.x + (vr.w - pw) / 2;
617
+
618
+ // Dessin uniquement si la page est dans la zone visible
619
+ if (y + ph >= vr.y && y <= vr.y + vr.h) {
620
+ ctx.shadowColor = this._colors.pageShadow;
621
+ ctx.shadowBlur = 8;
622
+ ctx.shadowOffsetY = 2;
623
+ ctx.fillStyle = this.pageBackground;
624
+ ctx.fillRect(px, y, pw, ph);
625
+ ctx.shadowBlur = 0;
626
+ ctx.shadowOffsetY = 0;
627
+
628
+ if (img) {
629
+ ctx.drawImage(img, 0, 0, img.width, img.height, px, y, pw, ph);
630
+ } else {
631
+ ctx.fillStyle = '#ccc';
632
+ ctx.font = '13px sans-serif';
633
+ ctx.textAlign = 'center';
634
+ ctx.textBaseline = 'middle';
635
+ ctx.fillText(`Page ${p}…`, px + pw / 2, y + ph / 2);
636
+ }
637
+
638
+ ctx.fillStyle = 'rgba(0,0,0,0.28)';
639
+ ctx.font = '11px sans-serif';
640
+ ctx.textAlign = 'right';
641
+ ctx.textBaseline = 'bottom';
642
+ ctx.fillText(`${p} / ${this.totalPages}`, px + pw - 6, y + ph - 4);
643
+ }
556
644
 
557
- } catch (err) {
558
- this.loading = false;
559
- this.error = err.message || 'Erreur lors du chargement du PDF';
560
- this._renderErrorState();
561
- this.onErrorCb(this.error);
645
+ y += ph + pad;
562
646
  }
563
- }
564
647
 
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;
648
+ this._drawScrollbar(ctx, vr);
612
649
  }
613
650
 
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
- });
651
+ _drawScrollbar(ctx, vr) {
652
+ if (this._maxScrollY <= 0) return;
653
+ const tw = 5, tr = 3;
654
+ const tx = vr.x + vr.w - tw - 3;
655
+ const ty = vr.y + 4, th = vr.h - 8;
656
+ const ratio = vr.h / (vr.h + this._maxScrollY);
657
+ const thumbH = Math.max(24, th * ratio);
658
+ const thumbY = ty + (this._scrollY / this._maxScrollY) * (th - thumbH);
659
+ ctx.fillStyle = 'rgba(0,0,0,0.08)';
660
+ this._rr(ctx, tx, ty, tw, th, tr);
661
+ ctx.fill();
662
+ ctx.fillStyle = 'rgba(0,0,0,0.32)';
663
+ this._rr(ctx, tx, thumbY, tw, thumbH, tr);
664
+ ctx.fill();
665
+ }
638
666
 
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;
667
+ // ─── États visuels ───────────────────────────────────────────────────────────
668
+
669
+ _drawLoading(ctx, vr) {
670
+ const cx = vr.x + vr.w / 2, cy = vr.y + vr.h / 2 - 20;
671
+ ctx.strokeStyle = 'rgba(0,0,0,0.1)';
672
+ ctx.lineWidth = 4;
673
+ ctx.beginPath();
674
+ ctx.arc(cx, cy, 22, 0, Math.PI * 2);
675
+ ctx.stroke();
676
+ ctx.strokeStyle = this._primary;
677
+ ctx.lineWidth = 4;
678
+ ctx.lineCap = 'round';
679
+ ctx.beginPath();
680
+ ctx.arc(cx, cy, 22, this._spinAngle, this._spinAngle + 1.3);
681
+ ctx.stroke();
682
+ ctx.fillStyle = this._colors.onSurfaceVariant || '#49454F';
683
+ ctx.font = '13px sans-serif';
684
+ ctx.textAlign = 'center';
685
+ ctx.textBaseline = 'top';
686
+ ctx.fillText('Chargement...', cx, cy + 32);
661
687
  }
662
688
 
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;
689
+ _drawEmpty(ctx, vr) {
690
+ const cx = vr.x + vr.w / 2, cy = vr.y + vr.h / 2 - 20;
691
+ ctx.globalAlpha = 0.35;
692
+ ctx.fillStyle = this._colors.onSurfaceVariant || '#49454F';
693
+ ctx.beginPath();
694
+ const fx = cx - 20, fy = cy - 26;
695
+ ctx.moveTo(fx + 6, fy);
696
+ ctx.lineTo(fx + 28, fy);
697
+ ctx.lineTo(fx + 40, fy + 14);
698
+ ctx.lineTo(fx + 40, fy + 54);
699
+ ctx.quadraticCurveTo(fx + 40, fy + 58, fx + 36, fy + 58);
700
+ ctx.lineTo(fx + 4, fy + 58);
701
+ ctx.quadraticCurveTo(fx, fy + 58, fx, fy + 54);
702
+ ctx.lineTo(fx, fy + 6);
703
+ ctx.quadraticCurveTo(fx, fy, fx + 6, fy);
704
+ ctx.closePath();
705
+ ctx.fill();
706
+ ctx.globalAlpha = 1;
707
+ ctx.fillStyle = this._colors.onSurfaceVariant || '#49454F';
708
+ ctx.font = '13px sans-serif';
709
+ ctx.textAlign = 'center';
710
+ ctx.textBaseline = 'top';
711
+ ctx.fillText('Aucun document chargé', cx, cy + 42);
675
712
  }
676
713
 
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
- });
714
+ _drawError(ctx, vr) {
715
+ const cx = vr.x + vr.w / 2, cy = vr.y + vr.h / 2 - 30;
716
+ const err = this._colors.error || '#BA1A1A';
717
+ ctx.fillStyle = err;
718
+ ctx.beginPath();
719
+ ctx.arc(cx, cy, 24, 0, Math.PI * 2);
720
+ ctx.fill();
721
+ ctx.fillStyle = '#fff';
722
+ ctx.font = 'bold 26px sans-serif';
723
+ ctx.textAlign = 'center';
724
+ ctx.textBaseline = 'middle';
725
+ ctx.fillText('!', cx, cy);
726
+ ctx.fillStyle = err;
727
+ ctx.font = '13px sans-serif';
728
+ ctx.textBaseline = 'top';
729
+ ctx.fillText((this.error || 'Erreur').substring(0, 60), cx, cy + 34);
730
+ const bx = cx - 48, by = cy + 62;
731
+ this._retryBtn = { x: bx, y: by, w: 96, h: 30 };
732
+
733
+ // Hover effect
734
+ if (this._hoveredBtn === 'retry') {
735
+ ctx.fillStyle = this._primary + 'dd';
736
+ } else {
737
+ ctx.fillStyle = this._primary;
738
+ }
739
+ this._rr(ctx, bx, by, 96, 30, this._isMat ? 15 : 8);
740
+ ctx.fill();
741
+ ctx.fillStyle = '#fff';
742
+ ctx.font = '13px sans-serif';
743
+ ctx.textBaseline = 'middle';
744
+ ctx.fillText('Réessayer', cx, by + 15);
745
+ }
692
746
 
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);
747
+ // ─── Miniatures ──────────────────────────────────────────────────────────────
748
+
749
+ _drawThumbs(ctx) {
750
+ const tr = this._thumbRect;
751
+ ctx.fillStyle = this._colors.thumbsBg;
752
+ ctx.fillRect(tr.x, tr.y, tr.w, tr.h);
753
+ ctx.strokeStyle = this._colors.thumbBorder;
754
+ ctx.lineWidth = 1;
755
+ ctx.beginPath();
756
+ ctx.moveTo(tr.x + tr.w, tr.y);
757
+ ctx.lineTo(tr.x + tr.w, tr.y + tr.h);
758
+ ctx.stroke();
759
+
760
+ ctx.save();
761
+ ctx.beginPath();
762
+ ctx.rect(tr.x, tr.y, tr.w, tr.h);
763
+ ctx.clip();
764
+ let ty = tr.y + 8;
765
+ for (let p = 1; p <= this.totalPages; p++) {
766
+ const img = this._thumbImages[p];
767
+ const tw = tr.w - 16;
768
+ const th = img ? Math.round(img.height * tw / img.width) : 70;
769
+ const tx = tr.x + 8;
770
+ if (ty + th > tr.y && ty < tr.y + tr.h) {
771
+ const active = p === this.currentPage;
772
+ ctx.strokeStyle = active ? this._colors.thumbActive : this._colors.thumbBorder;
773
+ ctx.lineWidth = active ? 2 : 1;
774
+ this._rr(ctx, tx, ty, tw, th, 3);
775
+ if (img) {
776
+ ctx.drawImage(img, tx, ty, tw, th);
777
+ ctx.stroke();
778
+ }
779
+ else {
780
+ ctx.fillStyle = '#fff';
781
+ ctx.fill();
782
+ ctx.stroke();
783
+ }
784
+ ctx.fillStyle = '#666';
785
+ ctx.font = '10px sans-serif';
786
+ ctx.textAlign = 'center';
787
+ ctx.textBaseline = 'top';
788
+ ctx.fillText(p, tx + tw / 2, ty + th + 2);
789
+ }
790
+ ty += th + 18;
698
791
  }
792
+ ctx.restore();
699
793
  }
700
794
 
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);
795
+ // ─── Toolbar ─────────────────────────────────────────────────────────────────
796
+
797
+ _drawToolbar(ctx) {
798
+ const tb = this._tbRect;
799
+ ctx.fillStyle = this._primary;
800
+ ctx.fillRect(tb.x, tb.y, tb.w, tb.h);
801
+ if (!this._isMat) {
802
+ ctx.strokeStyle = this._colors.toolbarBorder || '#C6C6C8';
803
+ ctx.lineWidth = 1;
804
+ ctx.beginPath();
805
+ ctx.moveTo(tb.x, tb.y + tb.h);
806
+ ctx.lineTo(tb.x + tb.w, tb.y + tb.h);
807
+ ctx.stroke();
808
+ }
809
+
810
+ ctx.save();
811
+ ctx.beginPath();
812
+ ctx.rect(tb.x, tb.y, tb.w, tb.h);
813
+ ctx.clip();
814
+
815
+ this._tbButtons = [];
816
+ this._pageInputRect = null;
817
+ let cx = tb.x + 12;
818
+ const my = tb.y + tb.h / 2;
819
+
820
+ // Titre
821
+ const title = typeof this.src === 'string' ? (this.src.split('/').pop() || 'Document.pdf') : 'Document.pdf';
822
+ ctx.fillStyle = 'rgba(255,255,255,0.92)';
823
+ ctx.font = `${this._isMat ? '500' : '600'} ${this._isMat ? 15 : 16}px sans-serif`;
824
+ ctx.textAlign = 'left';
825
+ ctx.textBaseline = 'middle';
826
+
827
+ // Calcul de la largeur disponible pour le titre
828
+ let availableWidth = tb.w - 200; // Réserver de l'espace pour les boutons
829
+
830
+ let t = title;
831
+ while (ctx.measureText(t).width > availableWidth && t.length > 2) {
832
+ t = t.slice(0, -1);
833
+ }
834
+ if (t !== title) t += '…';
835
+ ctx.fillText(t, cx, my);
836
+
837
+ // Boutons de navigation (si plus d'une page)
838
+ if (this.totalPages > 1 && this.allowNavigation) {
839
+ cx = tb.x + tb.w - 240;
840
+
841
+ // Page précédente
842
+ cx = this._btn(ctx, cx, my, 'prev', this._icChevron('left'), 0, this.currentPage <= 1);
843
+
844
+ // Indicateur de page
845
+ const pageText = `${this.currentPage}/${this.totalPages}`;
846
+ ctx.fillStyle = 'rgba(255,255,255,0.88)';
847
+ ctx.font = '13px sans-serif';
848
+ ctx.textAlign = 'center';
849
+ ctx.textBaseline = 'middle';
850
+ ctx.fillText(pageText, cx + 20, my);
851
+
852
+ // Enregistrer la zone cliquable pour l'indicateur de page
853
+ this._pageInputRect = { x: cx, y: my - 16, w: 44, h: 32 };
854
+
855
+ cx += 44;
856
+
857
+ // Page suivante
858
+ cx = this._btn(ctx, cx, my, 'next', this._icChevron('right'), 0, this.currentPage >= this.totalPages);
859
+
860
+ cx += 8; // Séparateur
861
+ }
862
+
863
+ // Boutons de zoom (toujours à droite)
864
+ cx = tb.x + tb.w - 120;
865
+
866
+ // Zoom out
867
+ if (this.allowZoom) {
868
+ cx = this._btn(ctx, cx, my, 'zoomOut', '−', 20, this.scale <= this.minScale);
869
+
870
+ // Pourcentage
871
+ const zl = `${Math.round(this.scale * 100)}%`;
872
+ ctx.fillStyle = 'rgba(255,255,255,0.88)';
873
+ ctx.font = '12px sans-serif';
874
+ ctx.textAlign = 'center';
875
+ ctx.textBaseline = 'middle';
876
+ ctx.fillText(zl, cx + 20, my);
877
+
878
+ cx += 44;
879
+
880
+ // Zoom in
881
+ cx = this._btn(ctx, cx, my, 'zoomIn', '+', 20, this.scale >= this.maxScale);
882
+ }
883
+
884
+ ctx.restore();
721
885
  }
722
886
 
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);
887
+ _btn(ctx, x, y, id, content, fontSize = 0, disabled = false) {
888
+ const bw = 32, bh = 32, bx = x, by = y - 16;
889
+
890
+ // Hover effect
891
+ if (this._hoveredBtn === id && !disabled) {
892
+ ctx.fillStyle = 'rgba(255,255,255,0.22)';
893
+ this._rr(ctx, bx, by, bw, bh, this._isMat ? 4 : 6);
894
+ ctx.fill();
895
+ }
896
+
897
+ this._tbButtons.push({ id, x: bx, y: by, w: bw, h: bh, disabled });
898
+ ctx.globalAlpha = disabled ? 0.35 : 1;
899
+
900
+ const isText = typeof content === 'string' && content.length <= 2;
901
+ if (isText) {
902
+ ctx.fillStyle = '#fff';
903
+ ctx.font = `${fontSize || 18}px sans-serif`;
904
+ ctx.textAlign = 'center';
905
+ ctx.textBaseline = 'middle';
906
+ ctx.fillText(content, bx + bw / 2, y);
907
+ } else {
908
+ // C'est un chemin SVG
909
+ ctx.save();
910
+ ctx.translate(bx + 7, y - 9);
911
+ ctx.strokeStyle = '#fff';
912
+ ctx.lineWidth = 2;
913
+ ctx.lineCap = 'round';
914
+ ctx.lineJoin = 'round';
915
+ ctx.stroke(new Path2D(content));
916
+ ctx.restore();
745
917
  }
918
+
919
+ ctx.globalAlpha = 1;
920
+ return x + bw + 4;
921
+ }
746
922
 
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...';
923
+ // ─── Barre de recherche ───────────────────────────────────────────────────────
750
924
 
751
- wrap.appendChild(spinner);
752
- wrap.appendChild(label);
753
- this._viewerEl.appendChild(wrap);
925
+ _drawSearchBar(ctx) {
926
+ const sb = this._sbRect;
927
+ if (sb.h === 0) return;
928
+
929
+ ctx.fillStyle = this._isMat ? this.m3Colors.surfaceVariant : '#F2F2F7';
930
+ ctx.fillRect(sb.x, sb.y, sb.w, sb.h);
931
+ ctx.strokeStyle = this._colors.outlineVariant || '#C6C6C8';
932
+ ctx.lineWidth = 1;
933
+ ctx.beginPath();
934
+ ctx.moveTo(sb.x, sb.y + sb.h);
935
+ ctx.lineTo(sb.x + sb.w, sb.y + sb.h);
936
+ ctx.stroke();
937
+
938
+ const fw = sb.w - 24 - 38, fh = sb.h - 16;
939
+ ctx.fillStyle = '#fff';
940
+ this._rr(ctx, sb.x + 12, sb.y + 8, fw, fh, this._isMat ? 4 : 8);
941
+ ctx.fill();
942
+ ctx.strokeStyle = this._colors.outline || '#C6C6C8';
943
+ this._rr(ctx, sb.x + 12, sb.y + 8, fw, fh, this._isMat ? 4 : 8);
944
+ ctx.stroke();
945
+ ctx.fillStyle = this._searchQuery ? '#000' : '#999';
946
+ ctx.font = '13px sans-serif';
947
+ ctx.textAlign = 'left';
948
+ ctx.textBaseline = 'middle';
949
+ ctx.fillText(this._searchQuery || 'Rechercher...', sb.x + 20, sb.y + sb.h / 2);
950
+
951
+ const clx = sb.x + sb.w - 34;
952
+ this._searchCloseRect = { x: clx, y: sb.y, w: 34, h: sb.h };
953
+
954
+ // Hover effect pour le bouton de fermeture
955
+ if (this._hoveredBtn === 'searchClose') {
956
+ ctx.fillStyle = '#444';
957
+ } else {
958
+ ctx.fillStyle = '#666';
959
+ }
960
+ ctx.font = '15px sans-serif';
961
+ ctx.textAlign = 'center';
962
+ ctx.textBaseline = 'middle';
963
+ ctx.fillText('✕', clx + 17, sb.y + sb.h / 2);
754
964
  }
755
965
 
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
- });
966
+ // ─── Gestion des clics ───────────────────────────────────────────────────────
967
+
968
+ _handleClick(x, y) {
969
+ console.log('Click at', x, y); // Debug
970
+
971
+ // Retry
972
+ if (this._retryBtn) {
973
+ const r = this._retryBtn;
974
+ if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) {
975
+ console.log('Retry clicked');
976
+ if (this.src) this._loadPDF(this.src);
977
+ return;
978
+ }
979
+ }
980
+
981
+ // Fermer recherche
982
+ if (this._searchCloseRect) {
983
+ const r = this._searchCloseRect;
984
+ if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) {
985
+ console.log('Search close clicked');
986
+ this._toggleSearch();
987
+ return;
988
+ }
989
+ }
990
+
991
+ // Toolbar
992
+ for (const b of this._tbButtons) {
993
+ if (!b.disabled && x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h) {
994
+ console.log('Button clicked:', b.id);
995
+ this._handleBtn(b.id);
996
+ return;
997
+ }
998
+ }
999
+
1000
+ // Indicateur de page (pour navigation)
1001
+ if (this._pageInputRect) {
1002
+ const r = this._pageInputRect;
1003
+ if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) {
1004
+ console.log('Page indicator clicked');
1005
+ this._promptPage();
1006
+ return;
1007
+ }
1008
+ }
1009
+
1010
+ // Miniatures
1011
+ if (this.totalPages > 1) {
1012
+ const tr = this._thumbRect;
1013
+ if (x >= tr.x && x <= tr.x + tr.w) {
1014
+ let ty = tr.y + 8;
1015
+ for (let p = 1; p <= this.totalPages; p++) {
1016
+ const img = this._thumbImages[p];
1017
+ const tw = tr.w - 16;
1018
+ const th = img ? Math.round(img.height * tw / img.width) : 70;
1019
+ if (y >= ty && y <= ty + th) {
1020
+ console.log('Thumbnail clicked:', p);
1021
+ this.goToPage(p);
1022
+ return;
1023
+ }
1024
+ ty += th + 18;
1025
+ }
1026
+ }
1027
+ }
1028
+ }
789
1029
 
790
- wrap.appendChild(retryBtn);
791
- this._viewerEl.appendChild(wrap);
1030
+ _handleBtn(id) {
1031
+ console.log('Handling button:', id);
1032
+ switch (id) {
1033
+ case 'prev':
1034
+ this.goToPreviousPage();
1035
+ break;
1036
+ case 'next':
1037
+ this.goToNextPage();
1038
+ break;
1039
+ case 'zoomIn':
1040
+ this._setScale(this.scale + 0.25);
1041
+ break;
1042
+ case 'zoomOut':
1043
+ this._setScale(this.scale - 0.25);
1044
+ break;
1045
+ case 'zoomFit':
1046
+ this._fitToWidth();
1047
+ break;
1048
+ case 'rotate':
1049
+ this._rotate();
1050
+ break;
1051
+ case 'search':
1052
+ this._toggleSearch();
1053
+ break;
1054
+ case 'download':
1055
+ this._download();
1056
+ break;
1057
+ case 'print':
1058
+ this._print();
1059
+ break;
1060
+ }
1061
+ this._redraw();
792
1062
  }
793
1063
 
794
- // ─── Actions ──────────────────────────────────────────────────────────────────
1064
+ // ─── Actions ─────────────────────────────────────────────────────────────────
795
1065
 
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)}%`;
1066
+ _setScale(s) {
1067
+ this.scale = Math.max(this.minScale, Math.min(this.maxScale, Math.round(s * 100) / 100));
805
1068
  this.onScaleChange(this.scale);
806
- if (this._pdfDoc) this._reRenderAll();
1069
+ this._reRenderAll();
807
1070
  }
808
1071
 
809
- /**
810
- * Ajuste le zoom à la largeur du viewer
811
- * @private
812
- */
813
1072
  async _fitToWidth() {
814
1073
  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);
1074
+ const vp = (await this._pdfDoc.getPage(1)).getViewport({ scale: 1 });
1075
+ this._setScale((this._viewerRect.w - 32) / vp.width);
820
1076
  }
821
1077
 
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
- }
834
-
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();
1078
+ _rotate() {
1079
+ this.rotation = (this.rotation + 90) % 360;
1080
+ this._reRenderAll();
842
1081
  }
843
1082
 
844
- /**
845
- * Télécharge le PDF
846
- * @private
847
- */
848
1083
  _download() {
849
- if (!this.src || typeof this.src !== 'string') return;
1084
+ if (typeof this.src !== 'string') return;
850
1085
  const a = document.createElement('a');
851
- a.href = this.src;
852
- a.download = this.src.split('/').pop() || 'document.pdf';
1086
+ a.href = this.src;
1087
+ a.download = this.src.split('/').pop() || 'document.pdf';
853
1088
  a.click();
854
1089
  }
855
1090
 
856
- /**
857
- * Imprime le PDF
858
- * @private
859
- */
860
1091
  _print() {
861
- if (!this.src || typeof this.src !== 'string') return;
1092
+ if (typeof this.src !== 'string') return;
862
1093
  const w = window.open(this.src);
863
1094
  if (w) w.addEventListener('load', () => w.print());
864
1095
  }
865
1096
 
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);
893
- }
1097
+ _toggleSearch() {
1098
+ this._searchOpen = !this._searchOpen;
1099
+ this._computeMaxScroll();
1100
+ this._redraw();
1101
+ }
1102
+
1103
+ _promptPage() {
1104
+ const v = prompt(`Aller à la page (1–${this.totalPages}) :`, this.currentPage);
1105
+ if (v !== null) {
1106
+ const p = parseInt(v);
1107
+ if (p >= 1 && p <= this.totalPages) this.goToPage(p);
894
1108
  }
895
1109
  }
896
1110
 
897
- // ─── Navigation ───────────────────────────────────────────────────────────────
1111
+ // ─── Navigation publique ─────────────────────────────────────────────────────
898
1112
 
899
- /**
900
- * Navigue vers une page spécifique
901
- * @param {number} page
902
- */
903
1113
  goToPage(page) {
904
1114
  if (!this._pdfDoc || page < 1 || page > this.totalPages) return;
905
1115
  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
- }
911
-
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' });
1116
+ this._scrollY = Math.max(0, Math.min(this._maxScrollY, this._pageScrollOffset(page) - 8));
1117
+ this.onPageChange(page, this.totalPages);
1118
+ this._redraw();
919
1119
  }
920
1120
 
921
- /**
922
- * Page précédente
923
- */
924
- goToPreviousPage() {
925
- if (this.currentPage > 1) this.goToPage(this.currentPage - 1);
1121
+ goToPreviousPage() {
1122
+ if (this.currentPage > 1) this.goToPage(this.currentPage - 1);
926
1123
  }
927
-
928
- /**
929
- * Page suivante
930
- */
931
- goToNextPage() {
932
- if (this.currentPage < this.totalPages) this.goToPage(this.currentPage + 1);
1124
+
1125
+ goToNextPage() {
1126
+ if (this.currentPage < this.totalPages) this.goToPage(this.currentPage + 1);
933
1127
  }
934
1128
 
935
1129
  // ─── API publique ─────────────────────────────────────────────────────────────
936
1130
 
937
- /**
938
- * Charge un nouveau document PDF
939
- * @param {string|Uint8Array} src - URL ou données binaires
940
- */
941
1131
  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);
949
- }
1132
+ this.src = src;
1133
+ this._pdfDoc = null;
1134
+ this._pageImages = {};
1135
+ this._thumbImages = {};
1136
+ this._scrollY = 0;
1137
+ this._loadPDF(src);
950
1138
  }
951
1139
 
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);
1140
+ setScale(s) {
1141
+ this._setScale(s);
958
1142
  }
959
1143
 
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();
1144
+ async getMetadata() {
1145
+ return this._pdfDoc ? this._pdfDoc.getMetadata() : null;
967
1146
  }
968
1147
 
969
- // ─── SVG Icons ────────────────────────────────────────────────────────────────
1148
+ // ─── Icônes SVG paths (18×18 viewbox) ────────────────────────────────────────
970
1149
 
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>`;
1150
+ _icChevron(d) {
1151
+ return d === 'left' ? 'M12 3 L5 9 L12 15' : 'M6 3 L13 9 L6 15';
976
1152
  }
977
-
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>`;
1153
+
1154
+ _icFit() {
1155
+ return 'M11 1 L17 1 L17 7 M1 11 L1 17 L7 17 M17 1 L10 8 M1 17 L8 10';
983
1156
  }
984
-
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>`;
1157
+
1158
+ _icRotate() {
1159
+ return 'M1 4 L1 9 L6 9 M2 11.5 A8 8 0 1 0 4 4.5';
989
1160
  }
990
-
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>`;
1161
+
1162
+ _icSearch() {
1163
+ return 'M7.5 13.5 A6 6 0 1 0 7.5 1.5 A6 6 0 1 0 7.5 13.5 M12 12 L17 17';
995
1164
  }
996
-
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>`;
1165
+
1166
+ _icDownload() {
1167
+ return 'M9 1 L9 11 M5 7 L9 11 L13 7 M1 14 L1 17 L17 17 L17 14';
1002
1168
  }
1003
-
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>`;
1169
+
1170
+ _icPrint() {
1171
+ return 'M4 7 L4 1 L14 1 L14 7 M4 15 L14 15 L14 11 L4 11 Z M1 7 L17 7 L17 14 L1 14 Z';
1010
1172
  }
1011
1173
 
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>`;
1174
+ // ─── Utilitaire roundRect ─────────────────────────────────────────────────────
1175
+
1176
+ _rr(ctx, x, y, w, h, r) {
1177
+ r = Math.min(r, w / 2, h / 2);
1178
+ ctx.beginPath();
1179
+ ctx.moveTo(x + r, y);
1180
+ ctx.lineTo(x + w - r, y);
1181
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
1182
+ ctx.lineTo(x + w, y + h - r);
1183
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
1184
+ ctx.lineTo(x + r, y + h);
1185
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r);
1186
+ ctx.lineTo(x, y + r);
1187
+ ctx.quadraticCurveTo(x, y, x + r, y);
1188
+ ctx.closePath();
1017
1189
  }
1018
1190
 
1019
- // ─── Rendu Canvas (shell) ─────────────────────────────────────────────────────
1020
-
1021
- /**
1022
- * Rendu Canvas : monte l'overlay DOM et synchronise la position
1023
- * @param {CanvasRenderingContext2D} ctx
1024
- */
1025
- draw(ctx) {
1026
- ctx.save();
1027
-
1028
- if (!this._mounted) this._mount();
1029
-
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
- }
1039
-
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);
1043
-
1044
- ctx.restore();
1045
- }
1046
-
1047
- /**
1048
- * Vérifie si un point est dans le composant
1049
- */
1050
1191
  isPointInside(x, y) {
1051
1192
  return x >= this.x && x <= this.x + this.width &&
1052
1193
  y >= this.y && y <= this.y + this.height;
1053
1194
  }
1054
1195
 
1055
- /**
1056
- * Nettoie les ressources
1057
- */
1058
1196
  destroy() {
1059
- if (this._containerEl && this._containerEl.parentNode) {
1060
- this._containerEl.parentNode.removeChild(this._containerEl);
1197
+ if (this._spinRAF) cancelAnimationFrame(this._spinRAF);
1198
+ if (this._momentumRAF) cancelAnimationFrame(this._momentumRAF);
1199
+ const canvas = this.framework && this.framework.canvas;
1200
+ if (canvas && this._eventsRegistered) {
1201
+ const h = this._boundHandlers;
1202
+ canvas.removeEventListener('mousedown', h.onMouseDown);
1203
+ canvas.removeEventListener('mousemove', h.onMouseMove);
1204
+ canvas.removeEventListener('mouseup', h.onMouseUp);
1205
+ canvas.removeEventListener('mouseleave', h.onMouseLeave);
1206
+ canvas.removeEventListener('click', h.onClick);
1207
+ canvas.removeEventListener('wheel', h.onWheel);
1208
+ canvas.removeEventListener('touchstart', h.onTouchStart);
1209
+ canvas.removeEventListener('touchmove', h.onTouchMove);
1210
+ canvas.removeEventListener('touchend', h.onTouchEnd);
1211
+ canvas.removeEventListener('touchcancel', h.onTouchEnd);
1061
1212
  }
1062
- this._pdfDoc = null;
1063
- this._mounted = false;
1064
- this._containerEl = null;
1065
- super.destroy && super.destroy();
1213
+ this._pdfDoc = null;
1214
+ if (super.destroy) super.destroy();
1066
1215
  }
1067
1216
  }
1068
1217