canvasframework 0.5.64 → 0.6.0

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,1218 +1,1057 @@
1
1
  import Component from '../core/Component.js';
2
2
 
3
3
  /**
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.
7
- *
8
- * @class
9
- * @extends Component
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).
10
9
  */
11
10
  class PDFViewer extends Component {
12
11
 
13
- /**
14
- * @param {CanvasFramework} framework
15
- * @param {Object} [options={}]
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]
36
- */
37
- constructor(framework, options = {}) {
38
- super(framework, options);
39
-
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
-
63
- this.onPageChange = options.onPageChange || (() => {});
64
- this.onScaleChange = options.onScaleChange || (() => {});
65
- this.onLoad = options.onLoad || (() => {});
66
- this.onErrorCb = options.onError || (() => {});
67
-
68
- // Layout
69
- this._toolbarHeight = this.showToolbar ? 52 : 0;
70
- this._thumbsWidth = 110; // toujours réservé, affiché si totalPages > 1
71
- this._searchOpen = false;
72
- this._searchQuery = '';
73
-
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;
112
-
113
- // Couleurs Material You 3
114
- this.m3Colors = {
115
- primary: '#6750A4',
116
- onPrimary: '#FFFFFF',
117
- surfaceVariant: '#E7E0EC',
118
- onSurface: '#1C1B1F',
119
- onSurfaceVariant: '#49454F',
120
- outline: '#79747E',
121
- outlineVariant: '#CAC4D0',
122
- error: '#BA1A1A',
123
- viewerBg: '#F7F2FA',
124
- thumbsBg: '#EFE9F4',
125
- thumbBorder: '#CAC4D0',
126
- thumbActive: '#6750A4',
127
- pageShadow: 'rgba(0,0,0,0.15)',
128
- };
129
-
130
- // Couleurs Cupertino
131
- this.cupertinoColors = {
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',
144
- };
145
-
146
- if (this.src) this._loadPDF(this.src);
147
- this._startSpinner();
148
- }
149
-
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 };
170
- }
171
-
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;
12
+ constructor(framework, options = {}) {
13
+ super(framework, options);
14
+
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 || 0.4;
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.toolbarColor = options.toolbarColor || null;
31
+ this.toolbarTextColor = options.toolbarTextColor || '#FFFFFF';
32
+ this.toolbarIconColor = options.toolbarIconColor || '#FFFFFF';
33
+ this.toolbarColor = options.toolbarColor || null;
34
+ this.allowSearch = options.allowSearch !== false;
35
+ this.minScale = options.minScale || 0.25;
36
+ this.maxScale = options.maxScale || 5.0;
37
+ this.backgroundColor = options.backgroundColor || null;
38
+ this.primaryColor = options.primaryColor || null;
39
+ this.pageBackground = options.pageBackground || '#FFFFFF';
40
+
41
+ this.onPageChange = options.onPageChange || (() => {});
42
+ this.onScaleChange = options.onScaleChange || (() => {});
43
+ this.onLoad = options.onLoad || (() => {});
44
+ this.onErrorCb = options.onError || (() => {});
45
+
46
+ // Layout
47
+ this._toolbarHeight = this.showToolbar ? 52 : 0;
48
+ this._searchOpen = false;
49
+ this._searchQuery = '';
50
+ this._thumbsWidth = 110;
51
+
52
+ // PDF state
53
+ this._pdfDoc = null;
54
+ this._pageImages = {};
55
+ this._thumbImages = {};
56
+
57
+ // Scroll interne du viewer
58
+ this._scrollY = 0;
59
+ this._maxScrollY = 0;
60
+ this._scrollX = 0;
61
+ this._maxScrollX = 0;
62
+
63
+ // Drag interne (scroll des pages)
64
+ this._dragging = false;
65
+ this._dragStartY = 0;
66
+ this._dragStartScroll = 0;
67
+ this._dragStartX = 0;
68
+ this._dragStartScrollX = 0;
69
+ this._pressX = undefined;
70
+ this._pressY = undefined;
71
+
72
+ // Hit areas — coordonnées MONDE (this.x + offset local)
73
+ this._tbButtons = [];
74
+ this._pageInputRect = null;
75
+ this._retryBtn = null;
76
+ this._searchCloseRect = null;
77
+ this._hoveredBtn = null;
78
+
79
+ // Spinner
80
+ this._spinAngle = 0;
81
+ this._spinRAF = null;
82
+
83
+ // Couleurs Material You 3
84
+ this.m3Colors = {
85
+ primary: '#6750A4',
86
+ onPrimary: '#FFFFFF',
87
+ surfaceVariant: '#E7E0EC',
88
+ onSurface: '#1C1B1F',
89
+ onSurfaceVariant: '#49454F',
90
+ outline: '#79747E',
91
+ outlineVariant: '#CAC4D0',
92
+ error: '#BA1A1A',
93
+ viewerBg: '#F7F2FA',
94
+ thumbsBg: '#EFE9F4',
95
+ thumbBorder: '#CAC4D0',
96
+ thumbActive: '#6750A4',
97
+ pageShadow: 'rgba(0,0,0,0.15)',
98
+ };
99
+
100
+ // Couleurs Cupertino
101
+ this.cupertinoColors = {
102
+ primary: '#007AFF',
103
+ onPrimary: '#FFFFFF',
104
+ error: '#FF3B30',
105
+ toolbarBorder: '#C6C6C8',
106
+ viewerBg: '#D1D1D6',
107
+ thumbsBg: '#F2F2F7',
108
+ thumbBorder: '#C6C6C8',
109
+ thumbActive: '#007AFF',
110
+ pageShadow: 'rgba(0,0,0,0.15)',
111
+ onSurfaceVariant: '#8E8E93',
112
+ outlineVariant: '#C6C6C8',
113
+ outline: '#C6C6C8',
114
+ };
115
+
116
+ this._startSpinner();
117
+ if (this.src) this._loadPDF(this.src);
118
+
119
+ // Gestion directe des events sur le canvas du framework
120
+ this._canvas = framework.canvas;
121
+ this._boundMouseDown = this._onMouseDown.bind(this);
122
+ this._boundMouseMove = this._onMouseMove.bind(this);
123
+ this._boundMouseUp = this._onMouseUp.bind(this);
124
+ this._canvas.addEventListener('mousedown', this._boundMouseDown);
125
+ this._canvas.addEventListener('mousemove', this._boundMouseMove);
126
+ this._canvas.addEventListener('mouseup', this._boundMouseUp);
127
+ this._canvas.addEventListener('touchstart', this._boundMouseDown, {
128
+ passive: false
129
+ });
130
+ this._canvas.addEventListener('touchmove', this._boundMouseMove, {
131
+ passive: false
132
+ });
133
+ this._canvas.addEventListener('touchend', this._boundMouseUp, {
134
+ passive: false
135
+ });
187
136
  }
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
- }
192
-
193
- _inSelf(x, y) {
194
- return x >= this.x && x <= this.x + this.width &&
195
- y >= this.y && y <= this.y + this.height;
196
- }
197
-
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;
201
- }
202
-
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;
137
+
138
+
139
+ _getPos(e) {
140
+ const rect = this._canvas.getBoundingClientRect();
141
+ let src;
142
+ if (e.changedTouches && e.changedTouches.length > 0) {
143
+ src = e.changedTouches[0];
144
+ } else if (e.touches && e.touches.length > 0) {
145
+ src = e.touches[0];
146
+ } else {
147
+ src = e;
231
148
  }
232
- }
233
- };
234
-
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)) {
149
+ return {
150
+ x: src.clientX - rect.left,
151
+ y: src.clientY - rect.top
152
+ };
153
+ }
154
+
155
+ _isInMe(x, y) {
156
+ return x >= this.x && x <= this.x + this.width &&
157
+ y >= this.y && y <= this.y + this.height;
158
+ }
159
+
160
+ _onMouseDown(e) {
161
+ const {
162
+ x,
163
+ y
164
+ } = this._getPos(e);
165
+ if (!this._isInMe(x, y)) return;
166
+ this._pressX = x;
167
+ this._pressY = y;
168
+ this._dragging = false;
169
+ this._dragStartScroll = this._scrollY;
170
+ }
171
+
172
+ _onMouseMove(e) {
173
+ const {
174
+ x,
175
+ y
176
+ } = this._getPos(e);
177
+ if (!this._isInMe(x, y)) return;
242
178
  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
- };
255
-
256
- const onMouseUp = () => {
257
- this._dragging = false;
258
- };
259
-
260
- const onMouseLeave = () => {
261
- this._dragging = false;
262
- this._hoveredBtn = null;
263
- this._redraw();
264
- };
265
-
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
-
281
- if (e.ctrlKey || e.metaKey) {
282
- // Zoom avec Ctrl+molette
283
- const delta = e.deltaY > 0 ? -0.1 : 0.1;
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();
179
+
180
+ if (this._pressX !== undefined) {
181
+ const vr = this._viewerRect;
182
+ const inViewer = this._pressX >= vr.x && this._pressX <= vr.x + vr.w &&
183
+ this._pressY >= vr.y && this._pressY <= vr.y + vr.h;
184
+ if (inViewer) {
185
+ const dx = Math.abs(x - this._pressX);
186
+ const dy = Math.abs(y - this._pressY);
187
+ if (dx > 5 || dy > 5 || this._dragging) {
188
+ if (!this._dragging) {
189
+ this._dragging = true;
190
+ this._dragStartY = this._pressY;
191
+ this._dragStartX = this._pressX;
192
+ this._dragStartScroll = this._scrollY;
193
+ this._dragStartScrollX = this._scrollX;
194
+ }
195
+ this._scrollY = Math.max(0, Math.min(this._maxScrollY,
196
+ this._dragStartScroll + (this._dragStartY - y)));
197
+ this._scrollX = Math.max(0, Math.min(this._maxScrollX,
198
+ this._dragStartScrollX + (this._dragStartX - x)));
199
+ this._syncPage();
200
+ this.markDirty();
201
+ }
202
+ }
291
203
  }
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;
204
+ }
205
+
206
+ _onMouseUp(e) {
207
+ const {
208
+ x,
209
+ y
210
+ } = this._getPos(e);
211
+ if (!this._isInMe(x, y)) {
212
+ this._pressX = undefined;
213
+ this._pressY = undefined;
214
+ return;
215
+ }
216
+ if (!this._dragging && this._pressX !== undefined) {
217
+ this._handleClick(x, y); // ← x,y directs, pas de surprise
317
218
  }
318
- e.preventDefault();
319
- }
320
- };
321
-
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
- };
332
-
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
- }
219
+ this._pressX = undefined;
220
+ this._pressY = undefined;
221
+ this._dragging = false;
381
222
  }
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
- }
223
+
224
+ destroy() {
225
+ this._canvas.removeEventListener('mousedown', this._boundMouseDown);
226
+ this._canvas.removeEventListener('mousemove', this._boundMouseMove);
227
+ this._canvas.removeEventListener('mouseup', this._boundMouseUp);
228
+ this._canvas.removeEventListener('touchstart', this._boundMouseDown);
229
+ this._canvas.removeEventListener('touchmove', this._boundMouseMove);
230
+ this._canvas.removeEventListener('touchend', this._boundMouseUp);
231
+ if (this._spinRAF) cancelAnimationFrame(this._spinRAF);
232
+ this._pdfDoc = null;
233
+ super.destroy?.();
389
234
  }
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
- }
235
+ // ─── Couleurs ────────────────────────────────────────────────────────────────
236
+
237
+ get _colors() {
238
+ return this.platform === 'material' ? this.m3Colors : this.cupertinoColors;
397
239
  }
398
-
399
- if (hover !== this._hoveredBtn) {
400
- this._hoveredBtn = hover;
401
- this._redraw();
240
+ get _primary() {
241
+ return this.primaryColor || this._colors.primary;
402
242
  }
403
- }
404
-
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);
436
- });
437
- }
438
-
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();
243
+ get _isMat() {
244
+ return this.platform === 'material';
470
245
  }
471
- }
472
-
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);
246
+
247
+ // ─── Géométrie en coordonnées MONDE ──────────────────────────────────────────
248
+
249
+ get _tbRect() {
250
+ return {
251
+ x: this.x,
252
+ y: this.y,
253
+ w: this.width,
254
+ h: this._toolbarHeight
255
+ };
482
256
  }
483
- }
484
-
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);
257
+ get _sbRect() {
258
+ return {
259
+ x: this.x,
260
+ y: this.y + this._toolbarHeight,
261
+ w: this.width,
262
+ h: this._searchOpen ? 44 : 0
263
+ };
494
264
  }
495
- }
496
-
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));
265
+ get _activeThumbsWidth() {
266
+ return this.totalPages > 1 ? this._thumbsWidth : 0;
508
267
  }
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
- }
517
-
518
- // ─── Layout & scroll ─────────────────────────────────────────────────────────
519
-
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;
268
+ get _viewerRect() {
269
+ const top = this._toolbarHeight + this._sbRect.h;
270
+ return {
271
+ x: this.x + this._activeThumbsWidth,
272
+ y: this.y + top,
273
+ w: this.width - this._activeThumbsWidth,
274
+ h: this.height - top,
275
+ };
525
276
  }
526
- this._maxScrollY = Math.max(0, total - vr.h);
527
- }
277
+ get _thumbRect() {
278
+ const top = this._toolbarHeight + this._sbRect.h;
279
+ return {
280
+ x: this.x,
281
+ y: this.y + top,
282
+ w: this._thumbsWidth,
283
+ h: this.height - top
284
+ };
285
+ }
286
+
287
+ // ─── Événements framework ────────────────────────────────────────────────────
288
+ // Le framework (checkComponentsAtPosition) appelle :
289
+ // onPress(x, y) sur 'start' avec comp.pressed = true
290
+ // onPress(x, y) sur 'end' avec comp.pressed = false (via onClick fallback)
291
+ // onMove(x, y) sur 'move'
292
+ //
293
+ // ATTENTION : le framework appelle onPress sur 'start' ET onClick sur 'end'.
294
+ // On surcharge onClick pour le clic final, et onPress pour le press initial.
295
+
296
+
297
+
298
+ // onClick est appelé par le framework sur 'end' quand comp.pressed était true
299
+ // On le remplace ici pour recevoir les coordonnées via onPress qu'on a mémorisé.
300
+ // Mais le framework appelle onClick() sans args... On utilise donc une autre approche :
301
+ // on surcharge handleClick via _lastPressX/Y mémorisés dans onPress.
302
+
303
+
528
304
 
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;
305
+ // ─── Logique de clic ─────────────────────────────────────────────────────────
306
+
307
+ _handleClick(x, y) {
308
+ console.log('_handleClick', x, y, 'thumbRect', this._thumbRect);
309
+ // Retry
310
+ if (this._retryBtn) {
311
+ const r = this._retryBtn;
312
+ if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) {
313
+ if (this.src) this._loadPDF(this.src);
314
+ return;
315
+ }
316
+ }
317
+
318
+ // Fermer recherche
319
+ if (this._searchCloseRect) {
320
+ const r = this._searchCloseRect;
321
+ if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) {
322
+ this._toggleSearch();
323
+ return;
324
+ }
325
+ }
326
+
327
+ // Boutons toolbar
328
+ for (const b of this._tbButtons) {
329
+ if (!b.disabled && x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h) {
330
+ this._handleBtn(b.id);
331
+ return;
332
+ }
333
+ }
334
+
335
+ // Indicateur de page
336
+ if (this._pageInputRect) {
337
+ const r = this._pageInputRect;
338
+ if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) {
339
+ this._promptPage();
340
+ return;
341
+ }
342
+ }
343
+
344
+ // Miniatures
345
+ if (this.totalPages > 1) {
346
+ const tr = this._thumbRect;
347
+ if (x >= tr.x && x <= tr.x + tr.w && y >= tr.y && y <= tr.y + tr.h) {
348
+ let ty = tr.y + 8;
349
+ for (let p = 1; p <= this.totalPages; p++) {
350
+ const img = this._thumbImages[p];
351
+ const tw = tr.w - 16;
352
+ const th = img ? Math.round(img.height * tw / img.width) : 70;
353
+ if (y >= ty && y <= ty + th) {
354
+ this.goToPage(p);
355
+ return;
356
+ }
357
+ ty += th + 18;
358
+ }
359
+ }
360
+ }
533
361
  }
534
- return y;
535
- }
536
-
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);
362
+
363
+ _checkHover(x, y) {
364
+ let hover = null;
365
+ for (const b of this._tbButtons) {
366
+ if (!b.disabled && x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h) {
367
+ hover = b.id;
368
+ break;
369
+ }
370
+ }
371
+ if (!hover && this._searchCloseRect) {
372
+ const r = this._searchCloseRect;
373
+ if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) hover = 'searchClose';
374
+ }
375
+ if (!hover && this._retryBtn) {
376
+ const r = this._retryBtn;
377
+ if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) hover = 'retry';
378
+ }
379
+ if (hover !== this._hoveredBtn) {
380
+ this._hoveredBtn = hover;
381
+ this.markDirty();
547
382
  }
548
- return;
549
- }
550
- y += h + 16;
551
383
  }
552
- }
553
-
554
- // ─── Dessin principal ────────────────────────────────────────────────────────
555
-
556
- draw(ctx) {
557
- this._registerEvents(); // one-shot
558
-
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();
580
-
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
384
+
385
+ // ─── Spinner ─────────────────────────────────────────────────────────────────
386
+
387
+ _startSpinner() {
388
+ const tick = () => {
389
+ if (this.loading) {
390
+ this._spinAngle = (this._spinAngle + 0.08) % (Math.PI * 2);
391
+ this.markDirty();
392
+ }
393
+ this._spinRAF = requestAnimationFrame(tick);
394
+ };
395
+ this._spinRAF = requestAnimationFrame(tick);
586
396
  }
587
- }
588
-
589
- // ─── Zone viewer ─────────────────────────────────────────────────────────────
590
-
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);
597
-
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);
603
-
604
- ctx.restore();
605
- }
606
-
607
- _drawPages(ctx, vr) {
608
- const pad = 16;
609
- let y = vr.y + pad - this._scrollY;
610
-
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
397
 
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
- }
398
+ // ─── PDF.js ──────────────────────────────────────────────────────────────────
399
+
400
+ async _loadPDFJS() {
401
+ if (window.pdfjsLib) return window.pdfjsLib;
402
+ return new Promise((resolve, reject) => {
403
+ const s = document.createElement('script');
404
+ s.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
405
+ s.onload = () => {
406
+ window.pdfjsLib.GlobalWorkerOptions.workerSrc =
407
+ 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
408
+ resolve(window.pdfjsLib);
409
+ };
410
+ s.onerror = () => reject(new Error('Impossible de charger PDF.js'));
411
+ document.head.appendChild(s);
412
+ });
413
+ }
644
414
 
645
- y += ph + pad;
415
+ async _loadPDF(src) {
416
+ this.loading = true;
417
+ this.error = null;
418
+ this._pageImages = {};
419
+ this._thumbImages = {};
420
+ this._scrollY = 0;
421
+ this.markDirty();
422
+ try {
423
+ const lib = await this._loadPDFJS();
424
+ const task = lib.getDocument(typeof src === 'string' ? src : {
425
+ data: src
426
+ });
427
+ this._pdfDoc = await task.promise;
428
+ this.totalPages = this._pdfDoc.numPages;
429
+ const promises = [];
430
+ for (let p = 1; p <= this.totalPages; p++) {
431
+ promises.push(this._renderPage(p), this._renderThumb(p));
432
+ }
433
+ await Promise.all(promises);
434
+ this.loading = false;
435
+ this._computeMaxScroll();
436
+ this.onLoad(this.totalPages);
437
+ this.markDirty();
438
+ } catch (err) {
439
+ this.loading = false;
440
+ this.error = err.message || 'Erreur de chargement';
441
+ this.onErrorCb(this.error);
442
+ this.markDirty();
443
+ }
646
444
  }
647
445
 
648
- this._drawScrollbar(ctx, vr);
649
- }
650
-
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
- }
666
-
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);
687
- }
688
-
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);
712
- }
713
-
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;
446
+ async _renderPage(p) {
447
+ try {
448
+ const page = await this._pdfDoc.getPage(p);
449
+ const vp = page.getViewport({
450
+ scale: this.scale,
451
+ rotation: this.rotation
452
+ });
453
+ const off = new OffscreenCanvas(Math.ceil(vp.width), Math.ceil(vp.height));
454
+ await page.render({
455
+ canvasContext: off.getContext('2d'),
456
+ viewport: vp
457
+ }).promise;
458
+ this._pageImages[p] = await createImageBitmap(off);
459
+ } catch (err) {
460
+ console.error(`Erreur page ${p}:`, err);
461
+ }
738
462
  }
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
- }
746
-
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();
463
+
464
+ async _renderThumb(p) {
465
+ try {
466
+ const page = await this._pdfDoc.getPage(p);
467
+ const vp = page.getViewport({
468
+ scale: 0.18,
469
+ rotation: this.rotation
470
+ });
471
+ const off = new OffscreenCanvas(Math.ceil(vp.width), Math.ceil(vp.height));
472
+ await page.render({
473
+ canvasContext: off.getContext('2d'),
474
+ viewport: vp
475
+ }).promise;
476
+ this._thumbImages[p] = await createImageBitmap(off);
477
+ } catch (err) {
478
+ console.error(`Erreur miniature ${p}:`, err);
778
479
  }
779
- else {
780
- ctx.fillStyle = '#fff';
781
- ctx.fill();
782
- ctx.stroke();
480
+ }
481
+
482
+ async _reRenderAll() {
483
+ if (!this._pdfDoc) return;
484
+ this._pageImages = {};
485
+ this._thumbImages = {};
486
+ this._scrollX = 0; // ← ajouter
487
+ this.loading = true;
488
+ this.markDirty();
489
+ const promises = [];
490
+ for (let p = 1; p <= this.totalPages; p++) {
491
+ promises.push(this._renderPage(p), this._renderThumb(p));
783
492
  }
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;
493
+ await Promise.all(promises);
494
+ this.loading = false;
495
+ this._computeMaxScroll();
496
+ this._scrollY = Math.min(this._scrollY, this._maxScrollY);
497
+ this.markDirty();
791
498
  }
792
- ctx.restore();
793
- }
794
-
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();
499
+
500
+ // ─── Scroll interne ───────────────────────────────────────────────────────────
501
+
502
+ _computeMaxScroll() {
503
+ const vr = this._viewerRect;
504
+ let totalH = 16;
505
+ let maxW = 0;
506
+ for (let p = 1; p <= this.totalPages; p++) {
507
+ totalH += (this._pageImages[p] ? this._pageImages[p].height : 200) + 16;
508
+ const w = this._pageImages[p] ? this._pageImages[p].width : 0;
509
+ if (w > maxW) maxW = w;
510
+ }
511
+ this._maxScrollY = Math.max(0, totalH - vr.h);
512
+ this._maxScrollX = Math.max(0, maxW + 32 - vr.w);
808
513
  }
809
514
 
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);
515
+ _pageScrollOffset(pageNum) {
516
+ let y = 16;
517
+ for (let p = 1; p < pageNum; p++) {
518
+ y += (this._pageImages[p] ? this._pageImages[p].height : 200) + 16;
519
+ }
520
+ return y;
833
521
  }
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
522
+
523
+ _syncPage() {
524
+ if (!this.totalPages) return;
525
+ const mid = this._scrollY + this._viewerRect.h / 2;
526
+ let y = 16;
527
+ for (let p = 1; p <= this.totalPages; p++) {
528
+ const h = this._pageImages[p] ? this._pageImages[p].height : 200;
529
+ if (mid <= y + h || p === this.totalPages) {
530
+ if (p !== this.currentPage) {
531
+ this.currentPage = p;
532
+ this.onPageChange(p, this.totalPages);
533
+ }
534
+ return;
535
+ }
536
+ y += h + 16;
537
+ }
861
538
  }
862
539
 
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);
540
+ // ─── Dessin ───────────────────────────────────────────────────────────────────
541
+
542
+ draw(ctx) {
543
+ ctx.save();
544
+ ctx.beginPath();
545
+ this._rr(ctx, this.x, this.y, this.width, this.height, this._isMat ? 4 : 12);
546
+ ctx.clip();
547
+
548
+ ctx.fillStyle = this.backgroundColor || this._colors.viewerBg;
549
+ ctx.fillRect(this.x, this.y, this.width, this.height);
550
+
551
+ if (this.totalPages > 1) this._drawThumbs(ctx);
552
+ this._drawViewer(ctx);
553
+ if (this.showToolbar) this._drawToolbar(ctx);
554
+ if (this._searchOpen) this._drawSearchBar(ctx);
555
+
556
+ ctx.strokeStyle = this._isMat ? 'rgba(0,0,0,0.12)' : (this._colors.toolbarBorder || '#C6C6C8');
557
+ ctx.lineWidth = 1;
558
+ ctx.beginPath();
559
+ this._rr(ctx, this.x, this.y, this.width, this.height, this._isMat ? 4 : 12);
560
+ ctx.stroke();
561
+
562
+ ctx.restore();
882
563
  }
883
564
 
884
- ctx.restore();
885
- }
886
-
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();
565
+ _drawViewer(ctx) {
566
+ const vr = this._viewerRect;
567
+ ctx.save();
568
+ ctx.beginPath();
569
+ ctx.rect(vr.x, vr.y, vr.w, vr.h);
570
+ ctx.clip();
571
+ ctx.fillStyle = this._colors.viewerBg;
572
+ ctx.fillRect(vr.x, vr.y, vr.w, vr.h);
573
+
574
+ const hasImages = Object.keys(this._pageImages).length > 0;
575
+ if (this.loading && !hasImages) this._drawLoading(ctx, vr);
576
+ else if (this.error && !hasImages) this._drawError(ctx, vr);
577
+ else if (!this.totalPages && !this.loading) this._drawEmpty(ctx, vr);
578
+ else this._drawPages(ctx, vr);
579
+
580
+ ctx.restore();
895
581
  }
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();
582
+
583
+ _drawPages(ctx, vr) {
584
+ const pad = 16;
585
+ let y = vr.y + pad - this._scrollY;
586
+
587
+ for (let p = 1; p <= this.totalPages; p++) {
588
+ const img = this._pageImages[p];
589
+ const ph = img ? img.height : 200;
590
+ const pw = img ? img.width : vr.w - pad * 2;
591
+
592
+ // Centrer si plus petit que le viewer, sinon scroll horizontal
593
+ const baseX = this._maxScrollX > 0 ?
594
+ vr.x + pad - this._scrollX :
595
+ vr.x + (vr.w - pw) / 2;
596
+
597
+ if (y + ph >= vr.y && y <= vr.y + vr.h) {
598
+ ctx.shadowColor = this._colors.pageShadow;
599
+ ctx.shadowBlur = 8;
600
+ ctx.shadowOffsetY = 2;
601
+ ctx.fillStyle = this.pageBackground;
602
+ ctx.fillRect(baseX, y, pw, ph);
603
+ ctx.shadowBlur = 0;
604
+ ctx.shadowOffsetY = 0;
605
+
606
+ if (img) {
607
+ ctx.drawImage(img, 0, 0, img.width, img.height, baseX, y, pw, ph);
608
+ } else {
609
+ ctx.fillStyle = '#ccc';
610
+ ctx.font = '13px sans-serif';
611
+ ctx.textAlign = 'center';
612
+ ctx.textBaseline = 'middle';
613
+ ctx.fillText(`Page ${p}…`, baseX + pw / 2, y + ph / 2);
614
+ }
615
+
616
+ ctx.fillStyle = 'rgba(0,0,0,0.28)';
617
+ ctx.font = '11px sans-serif';
618
+ ctx.textAlign = 'right';
619
+ ctx.textBaseline = 'bottom';
620
+ ctx.fillText(`${p} / ${this.totalPages}`, baseX + pw - 6, y + ph - 4);
621
+ }
622
+ y += ph + pad;
623
+ }
624
+ this._drawScrollbar(ctx, vr);
625
+ this._drawScrollbarH(ctx, vr);
917
626
  }
918
-
919
- ctx.globalAlpha = 1;
920
- return x + bw + 4;
921
- }
922
-
923
- // ─── Barre de recherche ───────────────────────────────────────────────────────
924
-
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';
627
+
628
+ _drawScrollbarH(ctx, vr) {
629
+ if (this._maxScrollX <= 0) return;
630
+ const th = 5,
631
+ tr = 3;
632
+ const ty = vr.y + vr.h - th - 3;
633
+ const tx = vr.x + 4,
634
+ tw = vr.w - 8;
635
+ const ratio = vr.w / (vr.w + this._maxScrollX);
636
+ const thumbW = Math.max(24, tw * ratio);
637
+ const thumbX = tx + (this._scrollX / this._maxScrollX) * (tw - thumbW);
638
+ ctx.fillStyle = 'rgba(0,0,0,0.08)';
639
+ this._rr(ctx, tx, ty, tw, th, tr);
640
+ ctx.fill();
641
+ ctx.fillStyle = 'rgba(0,0,0,0.32)';
642
+ this._rr(ctx, thumbX, ty, thumbW, th, tr);
643
+ ctx.fill();
959
644
  }
960
- ctx.font = '15px sans-serif';
961
- ctx.textAlign = 'center';
962
- ctx.textBaseline = 'middle';
963
- ctx.fillText('✕', clx + 17, sb.y + sb.h / 2);
964
- }
965
-
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
- }
645
+
646
+ _drawScrollbar(ctx, vr) {
647
+ if (this._maxScrollY <= 0) return;
648
+ const tw = 5,
649
+ tr = 3;
650
+ const tx = vr.x + vr.w - tw - 3;
651
+ const ty = vr.y + 4,
652
+ th = vr.h - 8;
653
+ const ratio = vr.h / (vr.h + this._maxScrollY);
654
+ const thumbH = Math.max(24, th * ratio);
655
+ const thumbY = ty + (this._scrollY / this._maxScrollY) * (th - thumbH);
656
+ ctx.fillStyle = 'rgba(0,0,0,0.08)';
657
+ this._rr(ctx, tx, ty, tw, th, tr);
658
+ ctx.fill();
659
+ ctx.fillStyle = 'rgba(0,0,0,0.32)';
660
+ this._rr(ctx, tx, thumbY, tw, thumbH, tr);
661
+ ctx.fill();
979
662
  }
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
- }
663
+
664
+ _drawLoading(ctx, vr) {
665
+ const cx = vr.x + vr.w / 2,
666
+ cy = vr.y + vr.h / 2 - 20;
667
+ ctx.strokeStyle = 'rgba(0,0,0,0.1)';
668
+ ctx.lineWidth = 4;
669
+ ctx.beginPath();
670
+ ctx.arc(cx, cy, 22, 0, Math.PI * 2);
671
+ ctx.stroke();
672
+ ctx.strokeStyle = this._primary;
673
+ ctx.lineWidth = 4;
674
+ ctx.lineCap = 'round';
675
+ ctx.beginPath();
676
+ ctx.arc(cx, cy, 22, this._spinAngle, this._spinAngle + 1.3);
677
+ ctx.stroke();
678
+ ctx.fillStyle = this._colors.onSurfaceVariant || '#49454F';
679
+ ctx.font = '13px sans-serif';
680
+ ctx.textAlign = 'center';
681
+ ctx.textBaseline = 'top';
682
+ ctx.fillText('Chargement...', cx, cy + 32);
989
683
  }
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
- }
684
+
685
+ _drawEmpty(ctx, vr) {
686
+ const cx = vr.x + vr.w / 2,
687
+ cy = vr.y + vr.h / 2 - 20;
688
+ ctx.globalAlpha = 0.35;
689
+ ctx.fillStyle = this._colors.onSurfaceVariant || '#49454F';
690
+ ctx.beginPath();
691
+ const fx = cx - 20,
692
+ fy = cy - 26;
693
+ ctx.moveTo(fx + 6, fy);
694
+ ctx.lineTo(fx + 28, fy);
695
+ ctx.lineTo(fx + 40, fy + 14);
696
+ ctx.lineTo(fx + 40, fy + 54);
697
+ ctx.quadraticCurveTo(fx + 40, fy + 58, fx + 36, fy + 58);
698
+ ctx.lineTo(fx + 4, fy + 58);
699
+ ctx.quadraticCurveTo(fx, fy + 58, fx, fy + 54);
700
+ ctx.lineTo(fx, fy + 6);
701
+ ctx.quadraticCurveTo(fx, fy, fx + 6, fy);
702
+ ctx.closePath();
703
+ ctx.fill();
704
+ ctx.globalAlpha = 1;
705
+ ctx.fillStyle = this._colors.onSurfaceVariant || '#49454F';
706
+ ctx.font = '13px sans-serif';
707
+ ctx.textAlign = 'center';
708
+ ctx.textBaseline = 'top';
709
+ ctx.fillText('Aucun document chargé', cx, cy + 42);
998
710
  }
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
- }
711
+
712
+ _drawError(ctx, vr) {
713
+ const cx = vr.x + vr.w / 2,
714
+ cy = vr.y + vr.h / 2 - 30;
715
+ const err = this._colors.error || '#BA1A1A';
716
+ ctx.fillStyle = err;
717
+ ctx.beginPath();
718
+ ctx.arc(cx, cy, 24, 0, Math.PI * 2);
719
+ ctx.fill();
720
+ ctx.fillStyle = '#fff';
721
+ ctx.font = 'bold 26px sans-serif';
722
+ ctx.textAlign = 'center';
723
+ ctx.textBaseline = 'middle';
724
+ ctx.fillText('!', cx, cy);
725
+ ctx.fillStyle = err;
726
+ ctx.font = '13px sans-serif';
727
+ ctx.textBaseline = 'top';
728
+ ctx.fillText((this.error || 'Erreur').substring(0, 60), cx, cy + 34);
729
+ const bx = cx - 48,
730
+ by = cy + 62;
731
+ this._retryBtn = {
732
+ x: bx,
733
+ y: by,
734
+ w: 96,
735
+ h: 30
736
+ };
737
+ ctx.fillStyle = this._hoveredBtn === 'retry' ? this._primary + 'dd' : this._primary;
738
+ this._rr(ctx, bx, by, 96, 30, this._isMat ? 15 : 8);
739
+ ctx.fill();
740
+ ctx.fillStyle = '#fff';
741
+ ctx.font = '13px sans-serif';
742
+ ctx.textBaseline = 'middle';
743
+ ctx.fillText('Réessayer', cx, by + 15);
1008
744
  }
1009
-
1010
- // Miniatures
1011
- if (this.totalPages > 1) {
1012
- const tr = this._thumbRect;
1013
- if (x >= tr.x && x <= tr.x + tr.w) {
745
+
746
+ _drawThumbs(ctx) {
747
+ const tr = this._thumbRect;
748
+ ctx.fillStyle = this._colors.thumbsBg;
749
+ ctx.fillRect(tr.x, tr.y, tr.w, tr.h);
750
+ ctx.strokeStyle = this._colors.thumbBorder;
751
+ ctx.lineWidth = 1;
752
+ ctx.beginPath();
753
+ ctx.moveTo(tr.x + tr.w, tr.y);
754
+ ctx.lineTo(tr.x + tr.w, tr.y + tr.h);
755
+ ctx.stroke();
756
+
757
+ ctx.save();
758
+ ctx.beginPath();
759
+ ctx.rect(tr.x, tr.y, tr.w, tr.h);
760
+ ctx.clip();
1014
761
  let ty = tr.y + 8;
1015
762
  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;
763
+ const img = this._thumbImages[p];
764
+ const tw = tr.w - 16;
765
+ const th = img ? Math.round(img.height * tw / img.width) : 70;
766
+ const tx = tr.x + 8;
767
+ if (ty + th > tr.y && ty < tr.y + tr.h) {
768
+ const active = p === this.currentPage;
769
+ ctx.strokeStyle = active ? this._colors.thumbActive : this._colors.thumbBorder;
770
+ ctx.lineWidth = active ? 2 : 1;
771
+ this._rr(ctx, tx, ty, tw, th, 3);
772
+ if (img) {
773
+ ctx.drawImage(img, tx, ty, tw, th);
774
+ ctx.stroke();
775
+ } else {
776
+ ctx.fillStyle = '#fff';
777
+ ctx.fill();
778
+ ctx.stroke();
779
+ }
780
+ ctx.fillStyle = '#666';
781
+ ctx.font = '10px sans-serif';
782
+ ctx.textAlign = 'center';
783
+ ctx.textBaseline = 'top';
784
+ ctx.fillText(p, tx + tw / 2, ty + th + 2);
785
+ }
786
+ ty += th + 18;
1025
787
  }
1026
- }
788
+ ctx.restore();
1027
789
  }
1028
- }
1029
-
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;
790
+
791
+ _drawToolbar(ctx) {
792
+ const tb = this._tbRect;
793
+ ctx.fillStyle = this.toolbarColor || this._primary;
794
+ ctx.fillRect(tb.x, tb.y, tb.w, tb.h);
795
+ if (!this._isMat) {
796
+ ctx.strokeStyle = this._colors.toolbarBorder || '#C6C6C8'; ctx.lineWidth = 1;
797
+ ctx.beginPath(); ctx.moveTo(tb.x, tb.y+tb.h); ctx.lineTo(tb.x+tb.w, tb.y+tb.h); ctx.stroke();
798
+ }
799
+
800
+ ctx.save(); ctx.beginPath(); ctx.rect(tb.x, tb.y, tb.w, tb.h); ctx.clip();
801
+
802
+ this._tbButtons = [];
803
+ this._pageInputRect = null;
804
+
805
+ let cx = tb.x + 12;
806
+ const my = tb.y + tb.h / 2;
807
+
808
+ // Titre
809
+ const title = typeof this.src === 'string' ? (this.src.split('/').pop() || 'Document.pdf') : 'Document.pdf';
810
+ ctx.fillStyle = this.toolbarTextColor;
811
+ ctx.font = `${this._isMat ? '500' : '600'} ${this._isMat ? 15 : 16}px sans-serif`;
812
+ ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
813
+ let t = title.length > 8 ? title.slice(0, 8) + '' : title;
814
+ ctx.fillText(t, cx, my);
815
+
816
+ // Navigation
817
+ if (this.totalPages > 1 && this.allowNavigation) {
818
+ cx = tb.x + tb.w - 240;
819
+ cx = this._btn(ctx, cx, my, 'prev', this._icChevron('left'), 0, this.currentPage <= 1);
820
+ ctx.fillStyle = this.toolbarTextColor;
821
+ ctx.font = '13px sans-serif';
822
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
823
+ ctx.fillText(`${this.currentPage}/${this.totalPages}`, cx + 20, my);
824
+ this._pageInputRect = { x: cx, y: my - 16, w: 44, h: 32 };
825
+ cx += 44;
826
+ cx = this._btn(ctx, cx, my, 'next', this._icChevron('right'), 0, this.currentPage >= this.totalPages);
827
+ cx += 8;
828
+ }
829
+
830
+ // Zoom
831
+ cx = tb.x + tb.w - 120;
832
+ if (this.allowZoom) {
833
+ cx = this._btn(ctx, cx, my, 'zoomOut', '−', 20, this.scale <= this.minScale);
834
+ ctx.fillStyle = this.toolbarTextColor;
835
+ ctx.font = '12px sans-serif';
836
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
837
+ ctx.fillText(`${Math.round(this.scale * 100)}%`, cx + 20, my);
838
+ cx += 44;
839
+ cx = this._btn(ctx, cx, my, 'zoomIn', '+', 20, this.scale >= this.maxScale);
840
+ }
841
+
842
+ ctx.restore();
843
+ }
844
+
845
+ _btn(ctx, x, y, id, content, fontSize = 0, disabled = false) {
846
+ const bw = 32, bh = 32, bx = x, by = y - 16;
847
+ if (this._hoveredBtn === id && !disabled) {
848
+ ctx.fillStyle = this.toolbarIconColor + '38';
849
+ this._rr(ctx, bx, by, bw, bh, this._isMat ? 4 : 6); ctx.fill();
850
+ }
851
+ this._tbButtons.push({ id, x: bx, y: by, w: bw, h: bh, disabled });
852
+ ctx.globalAlpha = disabled ? 0.35 : 1;
853
+ if (typeof content === 'string' && content.length <= 2) {
854
+ ctx.fillStyle = this.toolbarIconColor;
855
+ ctx.font = `${fontSize || 18}px sans-serif`;
856
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
857
+ ctx.fillText(content, bx + bw/2, y);
858
+ } else {
859
+ ctx.save(); ctx.translate(bx + 7, y - 9);
860
+ ctx.strokeStyle = this.toolbarIconColor;
861
+ ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
862
+ ctx.stroke(new Path2D(content));
863
+ ctx.restore();
864
+ }
865
+ ctx.globalAlpha = 1;
866
+ return x + bw + 4;
867
+ }
868
+
869
+ _drawSearchBar(ctx) {
870
+ const sb = this._sbRect;
871
+ if (sb.h === 0) return;
872
+ ctx.fillStyle = this._isMat ? this.m3Colors.surfaceVariant : '#F2F2F7';
873
+ ctx.fillRect(sb.x, sb.y, sb.w, sb.h);
874
+ ctx.strokeStyle = this._colors.outlineVariant || '#C6C6C8';
875
+ ctx.lineWidth = 1;
876
+ ctx.beginPath();
877
+ ctx.moveTo(sb.x, sb.y + sb.h);
878
+ ctx.lineTo(sb.x + sb.w, sb.y + sb.h);
879
+ ctx.stroke();
880
+
881
+ const fw = sb.w - 24 - 38,
882
+ fh = sb.h - 16;
883
+ ctx.fillStyle = '#fff';
884
+ this._rr(ctx, sb.x + 12, sb.y + 8, fw, fh, this._isMat ? 4 : 8);
885
+ ctx.fill();
886
+ ctx.strokeStyle = this._colors.outline || '#C6C6C8';
887
+ this._rr(ctx, sb.x + 12, sb.y + 8, fw, fh, this._isMat ? 4 : 8);
888
+ ctx.stroke();
889
+ ctx.fillStyle = this._searchQuery ? '#000' : '#999';
890
+ ctx.font = '13px sans-serif';
891
+ ctx.textAlign = 'left';
892
+ ctx.textBaseline = 'middle';
893
+ ctx.fillText(this._searchQuery || 'Rechercher...', sb.x + 20, sb.y + sb.h / 2);
894
+
895
+ const clx = sb.x + sb.w - 34;
896
+ this._searchCloseRect = {
897
+ x: clx,
898
+ y: sb.y,
899
+ w: 34,
900
+ h: sb.h
901
+ };
902
+ ctx.fillStyle = this._hoveredBtn === 'searchClose' ? '#444' : '#666';
903
+ ctx.font = '15px sans-serif';
904
+ ctx.textAlign = 'center';
905
+ ctx.textBaseline = 'middle';
906
+ ctx.fillText('✕', clx + 17, sb.y + sb.h / 2);
1060
907
  }
1061
- this._redraw();
1062
- }
1063
-
1064
- // ─── Actions ─────────────────────────────────────────────────────────────────
1065
-
1066
- _setScale(s) {
1067
- this.scale = Math.max(this.minScale, Math.min(this.maxScale, Math.round(s * 100) / 100));
1068
- this.onScaleChange(this.scale);
1069
- this._reRenderAll();
1070
- }
1071
-
1072
- async _fitToWidth() {
1073
- if (!this._pdfDoc) return;
1074
- const vp = (await this._pdfDoc.getPage(1)).getViewport({ scale: 1 });
1075
- this._setScale((this._viewerRect.w - 32) / vp.width);
1076
- }
1077
-
1078
- _rotate() {
1079
- this.rotation = (this.rotation + 90) % 360;
1080
- this._reRenderAll();
1081
- }
1082
-
1083
- _download() {
1084
- if (typeof this.src !== 'string') return;
1085
- const a = document.createElement('a');
1086
- a.href = this.src;
1087
- a.download = this.src.split('/').pop() || 'document.pdf';
1088
- a.click();
1089
- }
1090
-
1091
- _print() {
1092
- if (typeof this.src !== 'string') return;
1093
- const w = window.open(this.src);
1094
- if (w) w.addEventListener('load', () => w.print());
1095
- }
1096
-
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);
908
+
909
+ // ─── Actions ─────────────────────────────────────────────────────────────────
910
+
911
+ _handleBtn(id) {
912
+ switch (id) {
913
+ case 'prev':
914
+ this.goToPreviousPage();
915
+ break;
916
+ case 'next':
917
+ this.goToNextPage();
918
+ break;
919
+ case 'zoomIn':
920
+ this._setScale(this.scale + 0.25);
921
+ break;
922
+ case 'zoomOut':
923
+ this._setScale(this.scale - 0.25);
924
+ break;
925
+ case 'zoomFit':
926
+ this._fitToWidth();
927
+ break;
928
+ case 'rotate':
929
+ this._rotate();
930
+ break;
931
+ case 'search':
932
+ this._toggleSearch();
933
+ break;
934
+ case 'download':
935
+ this._download();
936
+ break;
937
+ case 'print':
938
+ this._print();
939
+ break;
940
+ }
941
+ this.markDirty();
942
+ }
943
+
944
+ _setScale(s) {
945
+ this.scale = Math.max(this.minScale, Math.min(this.maxScale, Math.round(s * 100) / 100));
946
+ this.onScaleChange(this.scale);
947
+ this._reRenderAll();
1108
948
  }
1109
- }
1110
-
1111
- // ─── Navigation publique ─────────────────────────────────────────────────────
1112
-
1113
- goToPage(page) {
1114
- if (!this._pdfDoc || page < 1 || page > this.totalPages) return;
1115
- this.currentPage = page;
1116
- this._scrollY = Math.max(0, Math.min(this._maxScrollY, this._pageScrollOffset(page) - 8));
1117
- this.onPageChange(page, this.totalPages);
1118
- this._redraw();
1119
- }
1120
-
1121
- goToPreviousPage() {
1122
- if (this.currentPage > 1) this.goToPage(this.currentPage - 1);
1123
- }
1124
-
1125
- goToNextPage() {
1126
- if (this.currentPage < this.totalPages) this.goToPage(this.currentPage + 1);
1127
- }
1128
-
1129
- // ─── API publique ─────────────────────────────────────────────────────────────
1130
-
1131
- load(src) {
1132
- this.src = src;
1133
- this._pdfDoc = null;
1134
- this._pageImages = {};
1135
- this._thumbImages = {};
1136
- this._scrollY = 0;
1137
- this._loadPDF(src);
1138
- }
1139
-
1140
- setScale(s) {
1141
- this._setScale(s);
1142
- }
1143
-
1144
- async getMetadata() {
1145
- return this._pdfDoc ? this._pdfDoc.getMetadata() : null;
1146
- }
1147
-
1148
- // ─── Icônes SVG paths (18×18 viewbox) ────────────────────────────────────────
1149
-
1150
- _icChevron(d) {
1151
- return d === 'left' ? 'M12 3 L5 9 L12 15' : 'M6 3 L13 9 L6 15';
1152
- }
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';
1156
- }
1157
-
1158
- _icRotate() {
1159
- return 'M1 4 L1 9 L6 9 M2 11.5 A8 8 0 1 0 4 4.5';
1160
- }
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';
1164
- }
1165
-
1166
- _icDownload() {
1167
- return 'M9 1 L9 11 M5 7 L9 11 L13 7 M1 14 L1 17 L17 17 L17 14';
1168
- }
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';
1172
- }
1173
-
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();
1189
- }
1190
-
1191
- isPointInside(x, y) {
1192
- return x >= this.x && x <= this.x + this.width &&
1193
- y >= this.y && y <= this.y + this.height;
1194
- }
1195
-
1196
- destroy() {
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);
949
+
950
+ async _fitToWidth() {
951
+ if (!this._pdfDoc) return;
952
+ const vp = (await this._pdfDoc.getPage(1)).getViewport({
953
+ scale: 1
954
+ });
955
+ this._setScale((this._viewerRect.w - 32) / vp.width);
1212
956
  }
1213
- this._pdfDoc = null;
1214
- if (super.destroy) super.destroy();
1215
- }
957
+
958
+ _rotate() {
959
+ this.rotation = (this.rotation + 90) % 360;
960
+ this._reRenderAll();
961
+ }
962
+
963
+ _download() {
964
+ if (typeof this.src !== 'string') return;
965
+ const a = document.createElement('a');
966
+ a.href = this.src;
967
+ a.download = this.src.split('/').pop() || 'document.pdf';
968
+ a.click();
969
+ }
970
+
971
+ _print() {
972
+ if (typeof this.src !== 'string') return;
973
+ const w = window.open(this.src);
974
+ if (w) w.addEventListener('load', () => w.print());
975
+ }
976
+
977
+ _toggleSearch() {
978
+ this._searchOpen = !this._searchOpen;
979
+ this._computeMaxScroll();
980
+ this.markDirty();
981
+ }
982
+
983
+ _promptPage() {
984
+ const v = prompt(`Aller à la page (1–${this.totalPages}) :`, this.currentPage);
985
+ if (v !== null) {
986
+ const p = parseInt(v);
987
+ if (p >= 1 && p <= this.totalPages) this.goToPage(p);
988
+ }
989
+ }
990
+
991
+ // ─── Navigation publique ─────────────────────────────────────────────────────
992
+
993
+ goToPage(page) {
994
+ if (!this._pdfDoc || page < 1 || page > this.totalPages) return;
995
+ this.currentPage = page;
996
+ this._scrollY = Math.max(0, Math.min(this._maxScrollY, this._pageScrollOffset(page) - 8));
997
+ this.onPageChange(page, this.totalPages);
998
+ this.markDirty();
999
+ }
1000
+
1001
+ goToPreviousPage() {
1002
+ if (this.currentPage > 1) this.goToPage(this.currentPage - 1);
1003
+ }
1004
+ goToNextPage() {
1005
+ if (this.currentPage < this.totalPages) this.goToPage(this.currentPage + 1);
1006
+ }
1007
+
1008
+ // ─── API publique ─────────────────────────────────────────────────────────────
1009
+
1010
+ load(src) {
1011
+ this.src = src;
1012
+ this._pdfDoc = null;
1013
+ this._pageImages = {};
1014
+ this._thumbImages = {};
1015
+ this._scrollY = 0;
1016
+ this._loadPDF(src);
1017
+ }
1018
+
1019
+ setScale(s) {
1020
+ this._setScale(s);
1021
+ }
1022
+ async getMetadata() {
1023
+ return this._pdfDoc ? this._pdfDoc.getMetadata() : null;
1024
+ }
1025
+
1026
+ // ─── Icônes ──────────────────────────────────────────────────────────────────
1027
+
1028
+ _icChevron(d) {
1029
+ return d === 'left' ? 'M12 3 L5 9 L12 15' : 'M6 3 L13 9 L6 15';
1030
+ }
1031
+
1032
+ // ─── roundRect ───────────────────────────────────────────────────────────────
1033
+
1034
+ _rr(ctx, x, y, w, h, r) {
1035
+ r = Math.min(r, w / 2, h / 2);
1036
+ ctx.beginPath();
1037
+ ctx.moveTo(x + r, y);
1038
+ ctx.lineTo(x + w - r, y);
1039
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
1040
+ ctx.lineTo(x + w, y + h - r);
1041
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
1042
+ ctx.lineTo(x + r, y + h);
1043
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r);
1044
+ ctx.lineTo(x, y + r);
1045
+ ctx.quadraticCurveTo(x, y, x + r, y);
1046
+ ctx.closePath();
1047
+ }
1048
+
1049
+ isPointInside(x, y) {
1050
+ return x >= this.x && x <= this.x + this.width &&
1051
+ y >= this.y && y <= this.y + this.height;
1052
+ }
1053
+
1054
+
1216
1055
  }
1217
1056
 
1218
1057
  export default PDFViewer;