canvasframework 0.3.8 → 0.3.10

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,397 @@
1
+ import FAB from './FAB.js';
2
+
3
+ /**
4
+ * Speed Dial FAB - FAB qui ouvre un menu d'actions
5
+ * @class
6
+ * @extends FAB
7
+ * @property {Array} actions - Liste des actions du menu
8
+ * @property {boolean} isOpen - État ouvert/fermé
9
+ * @property {number} animProgress - Progression de l'animation (0-1)
10
+ */
11
+ class SpeedDialFAB extends FAB {
12
+ /**
13
+ * Crée une instance de SpeedDialFAB
14
+ * @param {CanvasFramework} framework - Framework parent
15
+ * @param {Object} [options={}] - Options de configuration
16
+ * @param {Array} [options.actions=[]] - Actions du menu
17
+ * @example
18
+ * actions: [
19
+ * { icon: '✉', label: 'Email', bgColor: '#4CAF50', action: () => {...} },
20
+ * { icon: '📞', label: 'Call', bgColor: '#2196F3', action: () => {...} },
21
+ * { icon: '📍', label: Map', bgColor: '#FF9800', action: () => {...} }
22
+ * ]
23
+ */
24
+ constructor(framework, options = {}) {
25
+ super(framework, {
26
+ ...options,
27
+ icon: options.icon || '+'
28
+ });
29
+
30
+ this.actions = options.actions || [];
31
+ this.isOpen = false;
32
+ this.animProgress = 0;
33
+ this.actionSpacing = 72; // Espacement entre les mini FABs
34
+
35
+ // AJOUT: Flags pour gérer l'interaction
36
+ this.justClicked = false; // Pour éviter la fermeture immédiate
37
+ this.clickStartTime = 0;
38
+ this.clickStartY = 0;
39
+ this.isScrolling = false;
40
+ this.scrollThreshold = 5; // Seuil de mouvement pour détecter un scroll
41
+
42
+ // Initialiser les mini FABs
43
+ this.miniFabs = this.actions.map((action, index) => ({
44
+ ...action,
45
+ size: 48,
46
+ x: this.x,
47
+ y: this.y,
48
+ targetY: this.y - (index + 1) * this.actionSpacing,
49
+ currentY: this.y,
50
+ alpha: 0,
51
+ pressed: false
52
+ }));
53
+
54
+ // Overlay pour fermer le menu
55
+ this.showOverlay = false;
56
+
57
+ // Bind methods
58
+ this.onPress = this.handlePress.bind(this);
59
+ this.onMove = this.handleMove.bind(this);
60
+ }
61
+
62
+ /**
63
+ * Gère le début de la pression
64
+ * @param {number} x - Coordonnée X
65
+ * @param {number} y - Coordonnée Y
66
+ */
67
+ handlePress(x, y) {
68
+ this.justClicked = true;
69
+ this.clickStartTime = Date.now();
70
+ this.clickStartY = y;
71
+ this.isScrolling = false;
72
+
73
+ const adjustedY = y - this.framework.scrollOffset;
74
+
75
+ // 1. Vérifier si c'est le FAB principal
76
+ const isMainFabClick = x >= this.x && x <= this.x + this.width &&
77
+ adjustedY >= this.y && adjustedY <= this.y + this.height;
78
+
79
+ if (isMainFabClick) {
80
+ // Le FAB principal peut toujours être cliqué (ouvrir/fermer)
81
+ this.toggle();
82
+ super.handlePress(x, y);
83
+
84
+ // Empêcher la fermeture immédiate
85
+ setTimeout(() => {
86
+ this.justClicked = false;
87
+ }, 300);
88
+ return;
89
+ }
90
+
91
+ // 2. Si le menu est ouvert, vérifier les mini FABs
92
+ if (this.isOpen) {
93
+ for (let i = 0; i < this.miniFabs.length; i++) {
94
+ const fab = this.miniFabs[i];
95
+ if (fab.alpha < 0.5) continue; // Ignorer les FABs pas encore visibles
96
+
97
+ const fabX = this.x + (this.width - fab.size) / 2;
98
+ const fabY = fab.currentY;
99
+
100
+ const distance = Math.sqrt(
101
+ Math.pow(x - (fabX + fab.size / 2), 2) +
102
+ Math.pow(adjustedY - (fabY + fab.size / 2), 2)
103
+ );
104
+
105
+ if (distance <= fab.size / 2) {
106
+ // Action cliquée
107
+ fab.pressed = true;
108
+ setTimeout(() => {
109
+ fab.pressed = false;
110
+ if (fab.action) fab.action();
111
+ this.close();
112
+ }, 150);
113
+
114
+ // Empêcher la fermeture par handleClickOutside
115
+ this.justClicked = true;
116
+ setTimeout(() => {
117
+ this.justClicked = false;
118
+ }, 300);
119
+ return;
120
+ }
121
+ }
122
+
123
+ // 3. Clic sur overlay (n'importe où ailleurs)
124
+ // Ne pas fermer immédiatement, attendre la fin du mouvement
125
+ }
126
+
127
+ // Pas de return ici, la fermeture sera gérée par handleClickOutside
128
+ // avec vérification du mouvement
129
+ }
130
+
131
+ /**
132
+ * Gère le mouvement
133
+ * @param {number} x - Coordonnée X
134
+ * @param {number} y - Coordonnée Y
135
+ */
136
+ handleMove(x, y) {
137
+ // Détecter si c'est un scroll
138
+ const deltaY = Math.abs(y - this.clickStartY);
139
+ if (deltaY > this.scrollThreshold) {
140
+ this.isScrolling = true;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Ouvre le menu
146
+ */
147
+ open() {
148
+ if (this.isOpen) return;
149
+ this.isOpen = true;
150
+ this.showOverlay = true;
151
+ this.animate();
152
+ }
153
+
154
+ /**
155
+ * Ferme le menu
156
+ */
157
+ close() {
158
+ if (!this.isOpen) return;
159
+ this.isOpen = false;
160
+ this.animate();
161
+ }
162
+
163
+ /**
164
+ * Toggle l'état ouvert/fermé
165
+ */
166
+ toggle() {
167
+ this.isOpen ? this.close() : this.open();
168
+ }
169
+
170
+ /**
171
+ * Anime l'ouverture/fermeture
172
+ * @private
173
+ */
174
+ animate() {
175
+ const startTime = Date.now();
176
+ const duration = 300;
177
+
178
+ const step = () => {
179
+ const elapsed = Date.now() - startTime;
180
+ const progress = Math.min(elapsed / duration, 1);
181
+
182
+ // Easing out cubic
183
+ const eased = 1 - Math.pow(1 - progress, 3);
184
+
185
+ this.animProgress = this.isOpen ? eased : 1 - eased;
186
+
187
+ // Mettre à jour les positions des mini FABs
188
+ this.miniFabs.forEach((fab, index) => {
189
+ const delay = index * 0.05;
190
+ const fabProgress = Math.max(0, Math.min(1, (this.animProgress - delay) / (1 - delay)));
191
+
192
+ fab.currentY = this.y - (fabProgress * (index + 1) * this.actionSpacing);
193
+ fab.alpha = fabProgress;
194
+ });
195
+
196
+ if (progress < 1) {
197
+ requestAnimationFrame(step);
198
+ } else {
199
+ if (!this.isOpen) {
200
+ this.showOverlay = false;
201
+ }
202
+ }
203
+ };
204
+
205
+ step();
206
+ }
207
+
208
+ /**
209
+ * Dessine le Speed Dial FAB
210
+ * @param {CanvasRenderingContext2D} ctx - Contexte de dessin
211
+ */
212
+ draw(ctx) {
213
+ // Overlay semi-transparent avec gestion du clic
214
+ if (this.showOverlay) {
215
+ ctx.save();
216
+ ctx.fillStyle = `rgba(0, 0, 0, ${this.animProgress * 0.5})`;
217
+ ctx.fillRect(0, 0, this.framework.width, this.framework.height);
218
+ ctx.restore();
219
+
220
+ // Dessiner une zone invisible pour détecter les clics sur l'overlay
221
+ // On enregistre cette zone pour la détection
222
+ this.overlayActive = true;
223
+ } else {
224
+ this.overlayActive = false;
225
+ }
226
+
227
+ // Dessiner les mini FABs (de bas en haut)
228
+ if (this.animProgress > 0) {
229
+ for (let i = this.miniFabs.length - 1; i >= 0; i--) {
230
+ const fab = this.miniFabs[i];
231
+ if (fab.alpha > 0.01) {
232
+ this.drawMiniFab(ctx, fab);
233
+ }
234
+ }
235
+ }
236
+
237
+ // FAB principal
238
+ ctx.save();
239
+
240
+ // Rotation de l'icône + quand ouvert
241
+ if (this.icon === '+') {
242
+ ctx.save();
243
+ ctx.translate(this.x + this.width / 2, this.y + this.height / 2);
244
+ ctx.rotate((this.animProgress * 45) * Math.PI / 180);
245
+ ctx.translate(-(this.x + this.width / 2), -(this.y + this.height / 2));
246
+ }
247
+
248
+ super.draw(ctx);
249
+
250
+ if (this.icon === '+') {
251
+ ctx.restore();
252
+ }
253
+
254
+ ctx.restore();
255
+ }
256
+
257
+ /**
258
+ * Dessine un mini FAB
259
+ * @private
260
+ */
261
+ drawMiniFab(ctx, fab) {
262
+ ctx.save();
263
+ ctx.globalAlpha = fab.alpha;
264
+
265
+ const fabX = this.x + (this.width - fab.size) / 2;
266
+ const fabY = fab.currentY;
267
+
268
+ // Ombre
269
+ if (!fab.pressed) {
270
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
271
+ ctx.shadowBlur = 6;
272
+ ctx.shadowOffsetY = 3;
273
+ }
274
+
275
+ // Background
276
+ const bgColor = fab.bgColor || this.bgColor;
277
+ ctx.fillStyle = fab.pressed ? this.darkenColor(bgColor) : bgColor;
278
+ ctx.beginPath();
279
+ ctx.arc(fabX + fab.size / 2, fabY + fab.size / 2, fab.size / 2, 0, Math.PI * 2);
280
+ ctx.fill();
281
+
282
+ ctx.shadowColor = 'transparent';
283
+ ctx.shadowBlur = 0;
284
+ ctx.shadowOffsetY = 0;
285
+
286
+ // Icône
287
+ ctx.fillStyle = fab.iconColor || '#FFFFFF';
288
+ ctx.font = 'bold 20px sans-serif';
289
+ ctx.textAlign = 'center';
290
+ ctx.textBaseline = 'middle';
291
+ ctx.fillText(fab.icon, fabX + fab.size / 2, fabY + fab.size / 2);
292
+
293
+ // Label à gauche
294
+ if (fab.label && fab.alpha > 0.5) {
295
+ ctx.fillStyle = '#FFFFFF';
296
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
297
+ ctx.shadowBlur = 4;
298
+
299
+ const labelPadding = 12;
300
+ const labelHeight = 32;
301
+ ctx.font = '14px -apple-system, sans-serif';
302
+ const labelWidth = ctx.measureText(fab.label).width + labelPadding * 2;
303
+
304
+ // Fond du label
305
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
306
+ this.roundRect(
307
+ ctx,
308
+ fabX - labelWidth - 8,
309
+ fabY + fab.size / 2 - labelHeight / 2,
310
+ labelWidth,
311
+ labelHeight,
312
+ 4
313
+ );
314
+ ctx.fill();
315
+
316
+ ctx.shadowColor = 'transparent';
317
+ ctx.shadowBlur = 0;
318
+
319
+ // Texte du label
320
+ ctx.fillStyle = '#FFFFFF';
321
+ ctx.textAlign = 'right';
322
+ ctx.fillText(fab.label, fabX - 16, fabY + fab.size / 2);
323
+ }
324
+
325
+ ctx.restore();
326
+ }
327
+
328
+ /**
329
+ * Dessine un rectangle arrondi
330
+ * @private
331
+ */
332
+ roundRect(ctx, x, y, width, height, radius) {
333
+ ctx.beginPath();
334
+ ctx.moveTo(x + radius, y);
335
+ ctx.lineTo(x + width - radius, y);
336
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
337
+ ctx.lineTo(x + width, y + height - radius);
338
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
339
+ ctx.lineTo(x + radius, y + height);
340
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
341
+ ctx.lineTo(x, y + radius);
342
+ ctx.quadraticCurveTo(x, y, x + radius, y);
343
+ }
344
+
345
+ /**
346
+ * Vérifie si un point est dans le Speed Dial
347
+ */
348
+ /**
349
+ * Vérifie si un point est dans le Speed Dial
350
+ */
351
+ isPointInside(x, y) {
352
+ const adjustedY = y - this.framework.scrollOffset;
353
+
354
+ // Vérifier le FAB principal
355
+ if (x >= this.x && x <= this.x + this.width &&
356
+ adjustedY >= this.y && adjustedY <= this.y + this.height) {
357
+ return true;
358
+ }
359
+
360
+ // Si ouvert, vérifier les mini FABs
361
+ if (this.isOpen) {
362
+ for (let fab of this.miniFabs) {
363
+ if (fab.alpha < 0.01) continue;
364
+
365
+ const fabX = this.x + (this.width - fab.size) / 2;
366
+ const fabY = fab.currentY;
367
+
368
+ const distance = Math.sqrt(
369
+ Math.pow(x - (fabX + fab.size / 2), 2) +
370
+ Math.pow(adjustedY - (fabY + fab.size / 2), 2)
371
+ );
372
+
373
+ if (distance <= fab.size / 2) {
374
+ return true;
375
+ }
376
+ }
377
+
378
+ // LA CLÉ : Quand le menu est ouvert, TOUT L'ÉCRAN compte comme "inside"
379
+ // Cela empêche le framework d'appeler handleClickOutside()
380
+ return true;
381
+ }
382
+
383
+ return false;
384
+ }
385
+
386
+ /**
387
+ * Gère le clic en dehors (appelé par le framework)
388
+ */
389
+ handleClickOutside() {
390
+ // Ne pas fermer automatiquement
391
+ // La fermeture se fera par handleClick() seulement
392
+ // si on a vraiment cliqué en dehors
393
+ }
394
+
395
+
396
+ }
397
+ export default SpeedDialFAB;