canvasframework 0.5.59 → 0.5.60

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.
@@ -0,0 +1,493 @@
1
+ import Component from '../core/Component.js';
2
+
3
+ /**
4
+ * Popover (fenêtre contextuelle) avec variantes Material et Cupertino
5
+ * @class
6
+ * @extends Component
7
+ *
8
+ * Material: Carte avec ombre et flèche
9
+ * Cupertino: Style iOS avec blur background
10
+ */
11
+ class Popover extends Component {
12
+ /**
13
+ * Crée une instance de Popover
14
+ * @param {CanvasFramework} framework - Framework parent
15
+ * @param {Object} [options={}] - Options de configuration
16
+ * @param {string} [options.title] - Titre du popover
17
+ * @param {string} [options.content] - Contenu texte
18
+ * @param {Component} [options.customContent] - Composant personnalisé
19
+ * @param {string} [options.placement='top'] - Position: 'top', 'bottom', 'left', 'right'
20
+ * @param {Object} [options.anchor] - Point d'ancrage {x, y}
21
+ * @param {number} [options.maxWidth=280] - Largeur maximale
22
+ * @param {boolean} [options.showArrow=true] - Afficher la flèche
23
+ * @param {Function} [options.onClose] - Callback fermeture
24
+ */
25
+ constructor(framework, options = {}) {
26
+ super(framework, options);
27
+
28
+ this.platform = framework.platform;
29
+ this.title = options.title || null;
30
+ this.content = options.content || '';
31
+ this.customContent = options.customContent || null;
32
+ this.placement = options.placement || 'top';
33
+ this.anchor = options.anchor || null;
34
+ this.maxWidth = options.maxWidth || 280;
35
+ this.showArrow = options.showArrow !== false;
36
+ this.onClose = options.onClose || (() => {});
37
+
38
+ this.visible = false;
39
+ this.arrowSize = 12;
40
+ this.padding = this.platform === 'material' ? 16 : 12;
41
+
42
+ // Couleurs
43
+ if (this.platform === 'material') {
44
+ this.backgroundColor = '#FFFFFF';
45
+ this.titleColor = '#000000';
46
+ this.textColor = '#757575';
47
+ this.borderColor = '#E0E0E0';
48
+ this.elevation = 8;
49
+ } else {
50
+ this.backgroundColor = 'rgba(242, 242, 247, 0.98)';
51
+ this.titleColor = '#000000';
52
+ this.textColor = '#3C3C43';
53
+ this.borderColor = 'rgba(0, 0, 0, 0.1)';
54
+ }
55
+
56
+ // Calculer dimensions
57
+ this.calculateDimensions();
58
+
59
+ // Animation
60
+ this.animationProgress = 0;
61
+ this.animating = false;
62
+ // ✅ Flag pour éviter les doubles appels
63
+ this._isShowing = false;
64
+ }
65
+
66
+ /**
67
+ * Calcule les dimensions du popover
68
+ * @private
69
+ */
70
+ calculateDimensions() {
71
+ const ctx = this.framework.ctx;
72
+ ctx.save();
73
+
74
+ // Mesurer le texte
75
+ let contentHeight = 0;
76
+ let contentWidth = 0;
77
+
78
+ // Titre
79
+ if (this.title) {
80
+ ctx.font = `600 ${this.platform === 'material' ? 16 : 17}px ${this.platform === 'material' ? 'Roboto' : '-apple-system'}, sans-serif`;
81
+ const titleWidth = ctx.measureText(this.title).width;
82
+ contentWidth = Math.max(contentWidth, titleWidth);
83
+ contentHeight += this.platform === 'material' ? 24 : 22;
84
+ contentHeight += 8; // Espacement
85
+ }
86
+
87
+ // Contenu
88
+ if (this.content) {
89
+ ctx.font = `400 ${this.platform === 'material' ? 14 : 15}px ${this.platform === 'material' ? 'Roboto' : '-apple-system'}, sans-serif`;
90
+
91
+ // Wrap text
92
+ const words = this.content.split(' ');
93
+ const lines = [];
94
+ let currentLine = '';
95
+
96
+ for (let word of words) {
97
+ const testLine = currentLine + (currentLine ? ' ' : '') + word;
98
+ const metrics = ctx.measureText(testLine);
99
+
100
+ if (metrics.width > this.maxWidth - this.padding * 2 && currentLine) {
101
+ lines.push(currentLine);
102
+ currentLine = word;
103
+ } else {
104
+ currentLine = testLine;
105
+ }
106
+ }
107
+ if (currentLine) lines.push(currentLine);
108
+
109
+ this.contentLines = lines;
110
+ contentHeight += lines.length * (this.platform === 'material' ? 20 : 21);
111
+
112
+ lines.forEach(line => {
113
+ const lineWidth = ctx.measureText(line).width;
114
+ contentWidth = Math.max(contentWidth, lineWidth);
115
+ });
116
+ }
117
+
118
+ // Custom content
119
+ if (this.customContent) {
120
+ contentWidth = Math.max(contentWidth, this.customContent.width);
121
+ contentHeight += this.customContent.height;
122
+ }
123
+
124
+ this.width = Math.min(this.maxWidth, contentWidth + this.padding * 2);
125
+ this.height = contentHeight + this.padding * 2;
126
+
127
+ ctx.restore();
128
+ }
129
+
130
+ /**
131
+ * Positionne le popover par rapport à l'ancre
132
+ * @private
133
+ */
134
+ positionPopover() {
135
+ if (!this.anchor) return;
136
+
137
+ const arrowOffset = this.showArrow ? this.arrowSize : 0;
138
+
139
+ switch (this.placement) {
140
+ case 'top':
141
+ this.x = this.anchor.x - this.width / 2;
142
+ this.y = this.anchor.y - this.height - arrowOffset;
143
+ break;
144
+
145
+ case 'bottom':
146
+ this.x = this.anchor.x - this.width / 2;
147
+ this.y = this.anchor.y + arrowOffset;
148
+ break;
149
+
150
+ case 'left':
151
+ this.x = this.anchor.x - this.width - arrowOffset;
152
+ this.y = this.anchor.y - this.height / 2;
153
+ break;
154
+
155
+ case 'right':
156
+ this.x = this.anchor.x + arrowOffset;
157
+ this.y = this.anchor.y - this.height / 2;
158
+ break;
159
+ }
160
+
161
+ // Ajuster si hors écran
162
+ const canvas = this.framework.canvas;
163
+ this.x = Math.max(10, Math.min(this.x, canvas.width - this.width - 10));
164
+ this.y = Math.max(10, Math.min(this.y, canvas.height - this.height - 10));
165
+ }
166
+
167
+ /**
168
+ * Dessine la flèche
169
+ * @private
170
+ */
171
+ drawArrow(ctx) {
172
+ if (!this.showArrow || !this.anchor) return;
173
+
174
+ const size = this.arrowSize;
175
+
176
+ ctx.fillStyle = this.backgroundColor;
177
+ ctx.strokeStyle = this.borderColor;
178
+ ctx.lineWidth = 1;
179
+
180
+ ctx.beginPath();
181
+
182
+ switch (this.placement) {
183
+ case 'top':
184
+ const bottomX = this.anchor.x;
185
+ ctx.moveTo(bottomX, this.y + this.height);
186
+ ctx.lineTo(bottomX - size / 2, this.y + this.height + size);
187
+ ctx.lineTo(bottomX + size / 2, this.y + this.height + size);
188
+ break;
189
+
190
+ case 'bottom':
191
+ const topX = this.anchor.x;
192
+ ctx.moveTo(topX, this.y);
193
+ ctx.lineTo(topX - size / 2, this.y - size);
194
+ ctx.lineTo(topX + size / 2, this.y - size);
195
+ break;
196
+
197
+ case 'left':
198
+ const rightY = this.anchor.y;
199
+ ctx.moveTo(this.x + this.width, rightY);
200
+ ctx.lineTo(this.x + this.width + size, rightY - size / 2);
201
+ ctx.lineTo(this.x + this.width + size, rightY + size / 2);
202
+ break;
203
+
204
+ case 'right':
205
+ const leftY = this.anchor.y;
206
+ ctx.moveTo(this.x, leftY);
207
+ ctx.lineTo(this.x - size, leftY - size / 2);
208
+ ctx.lineTo(this.x - size, leftY + size / 2);
209
+ break;
210
+ }
211
+
212
+ ctx.closePath();
213
+ ctx.fill();
214
+ ctx.stroke();
215
+ }
216
+
217
+ /**
218
+ * Dessine le popover Material
219
+ * @private
220
+ */
221
+ drawMaterial(ctx) {
222
+ ctx.save();
223
+
224
+ // Ombre
225
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
226
+ ctx.shadowBlur = this.elevation * 2;
227
+ ctx.shadowOffsetY = this.elevation;
228
+
229
+ // Background
230
+ ctx.fillStyle = this.backgroundColor;
231
+ this.roundRect(ctx, this.x, this.y, this.width, this.height, 8);
232
+ ctx.fill();
233
+
234
+ ctx.shadowColor = 'transparent';
235
+
236
+ // Bordure légère
237
+ ctx.strokeStyle = this.borderColor;
238
+ ctx.lineWidth = 1;
239
+ ctx.stroke();
240
+
241
+ // Flèche
242
+ this.drawArrow(ctx);
243
+
244
+ ctx.restore();
245
+
246
+ // Contenu
247
+ this.drawContent(ctx);
248
+ }
249
+
250
+ /**
251
+ * Dessine le popover Cupertino (iOS)
252
+ * @private
253
+ */
254
+ drawCupertino(ctx) {
255
+ ctx.save();
256
+
257
+ // Blur background effect (simulé avec semi-transparence)
258
+ ctx.fillStyle = this.backgroundColor;
259
+ this.roundRect(ctx, this.x, this.y, this.width, this.height, 12);
260
+ ctx.fill();
261
+
262
+ // Bordure
263
+ ctx.strokeStyle = this.borderColor;
264
+ ctx.lineWidth = 0.5;
265
+ ctx.stroke();
266
+
267
+ // Flèche
268
+ this.drawArrow(ctx);
269
+
270
+ ctx.restore();
271
+
272
+ // Contenu
273
+ this.drawContent(ctx);
274
+ }
275
+
276
+ /**
277
+ * Dessine le contenu
278
+ * @private
279
+ */
280
+ drawContent(ctx) {
281
+ let currentY = this.y + this.padding;
282
+
283
+ // Titre
284
+ if (this.title) {
285
+ ctx.fillStyle = this.titleColor;
286
+ ctx.font = `600 ${this.platform === 'material' ? 16 : 17}px ${this.platform === 'material' ? 'Roboto' : '-apple-system'}, sans-serif`;
287
+ ctx.textAlign = 'left';
288
+ ctx.textBaseline = 'top';
289
+ ctx.fillText(this.title, this.x + this.padding, currentY);
290
+ currentY += this.platform === 'material' ? 24 : 22;
291
+ currentY += 8;
292
+ }
293
+
294
+ // Contenu texte
295
+ if (this.content && this.contentLines) {
296
+ ctx.fillStyle = this.textColor;
297
+ ctx.font = `400 ${this.platform === 'material' ? 14 : 15}px ${this.platform === 'material' ? 'Roboto' : '-apple-system'}, sans-serif`;
298
+
299
+ this.contentLines.forEach(line => {
300
+ ctx.fillText(line, this.x + this.padding, currentY);
301
+ currentY += this.platform === 'material' ? 20 : 21;
302
+ });
303
+ }
304
+
305
+ // Custom content
306
+ if (this.customContent) {
307
+ ctx.save();
308
+ ctx.translate(this.x + this.padding, currentY);
309
+ this.customContent.draw(ctx);
310
+ ctx.restore();
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Dessine le composant
316
+ * @param {CanvasRenderingContext2D} ctx - Contexte de dessin
317
+ */
318
+ draw(ctx) {
319
+ if (!this.visible) return;
320
+
321
+ // Animation d'apparition
322
+ if (this.animating) {
323
+ ctx.save();
324
+ ctx.globalAlpha = this.animationProgress;
325
+
326
+ const scale = 0.8 + (this.animationProgress * 0.2);
327
+ const centerX = this.x + this.width / 2;
328
+ const centerY = this.y + this.height / 2;
329
+
330
+ ctx.translate(centerX, centerY);
331
+ ctx.scale(scale, scale);
332
+ ctx.translate(-centerX, -centerY);
333
+ }
334
+
335
+ if (this.platform === 'material') {
336
+ this.drawMaterial(ctx);
337
+ } else {
338
+ this.drawCupertino(ctx);
339
+ }
340
+
341
+ if (this.animating) {
342
+ ctx.restore();
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Affiche le popover
348
+ * @param {Object} anchor - Point d'ancrage {x, y}
349
+ * @param {string} placement - Position
350
+ */
351
+ show(anchor, placement) {
352
+ // ✅ Éviter les appels multiples
353
+ if (this._isShowing) return;
354
+ this._isShowing = true;
355
+
356
+ if (anchor) this.anchor = anchor;
357
+ if (placement) this.placement = placement;
358
+
359
+ this.calculateDimensions();
360
+ this.positionPopover();
361
+
362
+ this.visible = true;
363
+ this.animating = true;
364
+ this.animationProgress = 0;
365
+
366
+ this.animateIn();
367
+
368
+ // ✅ Réinitialiser le flag après l'animation
369
+ setTimeout(() => {
370
+ this._isShowing = false;
371
+ }, this.splashOptions?.duration || 200);
372
+ }
373
+
374
+ /**
375
+ * Cache le popover
376
+ */
377
+ hide() {
378
+ this.animating = true;
379
+ this.animateOut();
380
+ }
381
+
382
+ /**
383
+ * Animation d'apparition
384
+ * @private
385
+ */
386
+ animateIn() {
387
+ const duration = 200;
388
+ const startTime = Date.now();
389
+
390
+ const animate = () => {
391
+ const elapsed = Date.now() - startTime;
392
+ this.animationProgress = Math.min(1, elapsed / duration);
393
+
394
+
395
+
396
+ if (this.animationProgress < 1) {
397
+ requestAnimationFrame(animate);
398
+ } else {
399
+ this.animating = false;
400
+ }
401
+ };
402
+
403
+ animate();
404
+ }
405
+
406
+ /**
407
+ * Animation de disparition
408
+ * @private
409
+ */
410
+ animateOut() {
411
+ const duration = 150;
412
+ const startTime = Date.now();
413
+ const startProgress = this.animationProgress;
414
+
415
+ const animate = () => {
416
+ const elapsed = Date.now() - startTime;
417
+ this.animationProgress = startProgress * (1 - elapsed / duration);
418
+
419
+
420
+ if (this.animationProgress > 0) {
421
+ requestAnimationFrame(animate);
422
+ } else {
423
+ this.animating = false;
424
+ this.visible = false;
425
+ this.onClose();
426
+ }
427
+ };
428
+
429
+ animate();
430
+ }
431
+
432
+ /**
433
+ * Gère le clic à l'extérieur
434
+ * @param {number} x - Coordonnée X
435
+ * @param {number} y - Coordonnée Y
436
+ * @returns {boolean} True si clic à l'extérieur
437
+ */
438
+ handleClickOutside(x, y) {
439
+ if (!this.visible) return false;
440
+
441
+ const isInside = x >= this.x && x <= this.x + this.width &&
442
+ y >= this.y && y <= this.y + this.height;
443
+
444
+ if (!isInside) {
445
+ this.hide();
446
+ return true;
447
+ }
448
+
449
+ return false;
450
+ }
451
+
452
+ /**
453
+ * Définit le contenu
454
+ * @param {string} content - Nouveau contenu
455
+ */
456
+ setContent(content) {
457
+ this.content = content;
458
+ this.calculateDimensions();
459
+ this.positionPopover();
460
+ }
461
+
462
+ /**
463
+ * Définit le titre
464
+ * @param {string} title - Nouveau titre
465
+ */
466
+ setTitle(title) {
467
+ this.title = title;
468
+ this.calculateDimensions();
469
+ this.positionPopover();
470
+ }
471
+
472
+ roundRect(ctx, x, y, width, height, radius) {
473
+ ctx.beginPath();
474
+ ctx.moveTo(x + radius, y);
475
+ ctx.lineTo(x + width - radius, y);
476
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
477
+ ctx.lineTo(x + width, y + height - radius);
478
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
479
+ ctx.lineTo(x + radius, y + height);
480
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
481
+ ctx.lineTo(x, y + radius);
482
+ ctx.quadraticCurveTo(x, y, x + radius, y);
483
+ ctx.closePath();
484
+ }
485
+
486
+ isPointInside(x, y) {
487
+ if (!this.visible) return false;
488
+ return x >= this.x && x <= this.x + this.width &&
489
+ y >= this.y && y <= this.y + this.height;
490
+ }
491
+ }
492
+
493
+ export default Popover;