canvasframework 0.3.8 → 0.3.9
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.
- package/components/Button.js +182 -62
- package/components/MorphingFAB.js +428 -0
- package/components/SpeedDialFAB.js +397 -0
- package/core/CanvasFramework.js +2 -0
- package/index.js +3 -1
- package/package.json +1 -1
package/components/Button.js
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
import Component from '../core/Component.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Bouton cliquable
|
|
4
|
+
* Bouton cliquable avec variantes Material et Cupertino
|
|
5
5
|
* @class
|
|
6
6
|
* @extends Component
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* @property {string} textColor - Couleur du texte
|
|
12
|
-
* @property {string} rippleColor - Couleur du ripple
|
|
13
|
-
* @property {number} elevation - Élévation (ombre)
|
|
14
|
-
* @property {Array} ripples - Effets ripple
|
|
7
|
+
*
|
|
8
|
+
* Types Material: 'filled', 'outlined', 'text', 'elevated', 'tonal'
|
|
9
|
+
* Types Cupertino: 'filled', 'gray', 'tinted', 'bordered', 'plain'
|
|
10
|
+
* Shapes: 'rounded', 'square', 'pill' (très arrondi)
|
|
15
11
|
*/
|
|
16
12
|
class Button extends Component {
|
|
17
13
|
/**
|
|
@@ -20,35 +16,135 @@ class Button extends Component {
|
|
|
20
16
|
* @param {Object} [options={}] - Options de configuration
|
|
21
17
|
* @param {string} [options.text='Button'] - Texte
|
|
22
18
|
* @param {number} [options.fontSize=16] - Taille de police
|
|
23
|
-
* @param {string} [options.
|
|
24
|
-
* @param {string} [options.
|
|
25
|
-
* @param {
|
|
19
|
+
* @param {string} [options.type] - Type de bouton (auto selon platform)
|
|
20
|
+
* @param {string} [options.shape='rounded'] - Forme: 'rounded', 'square', 'pill'
|
|
21
|
+
* @param {string} [options.bgColor] - Couleur personnalisée
|
|
22
|
+
* @param {string} [options.textColor] - Couleur du texte personnalisée
|
|
23
|
+
* @param {number} [options.elevation=2] - Élévation (Material elevated)
|
|
26
24
|
*/
|
|
27
25
|
constructor(framework, options = {}) {
|
|
28
26
|
super(framework, options);
|
|
29
27
|
this.text = options.text || 'Button';
|
|
30
28
|
this.fontSize = options.fontSize || 16;
|
|
31
29
|
this.platform = framework.platform;
|
|
30
|
+
this.shape = options.shape || 'rounded';
|
|
32
31
|
|
|
33
|
-
//
|
|
32
|
+
// Définir le type de bouton selon la plateforme
|
|
34
33
|
if (this.platform === 'material') {
|
|
35
|
-
this.
|
|
36
|
-
this.
|
|
37
|
-
this.rippleColor = 'rgba(255, 255, 255, 0.3)';
|
|
38
|
-
this.elevation = options.elevation || 2;
|
|
34
|
+
this.type = options.type || 'filled';
|
|
35
|
+
this.setupMaterialStyle(options);
|
|
39
36
|
} else {
|
|
40
|
-
this.
|
|
41
|
-
this.
|
|
42
|
-
this.borderRadius = 10;
|
|
37
|
+
this.type = options.type || 'filled';
|
|
38
|
+
this.setupCupertinoStyle(options);
|
|
43
39
|
}
|
|
44
40
|
|
|
45
|
-
//
|
|
41
|
+
// Effets ripple (Material uniquement)
|
|
46
42
|
this.ripples = [];
|
|
47
43
|
|
|
48
|
-
// Bind
|
|
44
|
+
// Bind
|
|
49
45
|
this.onPress = this.handlePress.bind(this);
|
|
50
46
|
}
|
|
51
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Configure le style Material Design
|
|
50
|
+
* @private
|
|
51
|
+
*/
|
|
52
|
+
setupMaterialStyle(options) {
|
|
53
|
+
const baseColor = options.bgColor || '#6200EE';
|
|
54
|
+
this.elevation = options.elevation || 2;
|
|
55
|
+
|
|
56
|
+
switch (this.type) {
|
|
57
|
+
case 'filled':
|
|
58
|
+
this.bgColor = baseColor;
|
|
59
|
+
this.textColor = options.textColor || '#FFFFFF';
|
|
60
|
+
this.borderWidth = 0;
|
|
61
|
+
this.rippleColor = 'rgba(255, 255, 255, 0.3)';
|
|
62
|
+
break;
|
|
63
|
+
|
|
64
|
+
case 'outlined':
|
|
65
|
+
this.bgColor = 'transparent';
|
|
66
|
+
this.textColor = options.textColor || baseColor;
|
|
67
|
+
this.borderColor = baseColor;
|
|
68
|
+
this.borderWidth = 1;
|
|
69
|
+
this.rippleColor = this.hexToRgba(baseColor, 0.2);
|
|
70
|
+
this.elevation = 0;
|
|
71
|
+
break;
|
|
72
|
+
|
|
73
|
+
case 'text':
|
|
74
|
+
this.bgColor = 'transparent';
|
|
75
|
+
this.textColor = options.textColor || baseColor;
|
|
76
|
+
this.borderWidth = 0;
|
|
77
|
+
this.rippleColor = this.hexToRgba(baseColor, 0.2);
|
|
78
|
+
this.elevation = 0;
|
|
79
|
+
break;
|
|
80
|
+
|
|
81
|
+
case 'elevated':
|
|
82
|
+
this.bgColor = options.bgColor || '#FFFFFF';
|
|
83
|
+
this.textColor = options.textColor || baseColor;
|
|
84
|
+
this.borderWidth = 0;
|
|
85
|
+
this.rippleColor = this.hexToRgba(baseColor, 0.2);
|
|
86
|
+
this.elevation = options.elevation || 4;
|
|
87
|
+
break;
|
|
88
|
+
|
|
89
|
+
case 'tonal':
|
|
90
|
+
this.bgColor = this.hexToRgba(baseColor, 0.3);
|
|
91
|
+
this.textColor = options.textColor || baseColor;
|
|
92
|
+
this.borderWidth = 0;
|
|
93
|
+
this.rippleColor = this.hexToRgba(baseColor, 0.3);
|
|
94
|
+
this.elevation = 0;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Configure le style Cupertino (iOS)
|
|
101
|
+
* @private
|
|
102
|
+
*/
|
|
103
|
+
setupCupertinoStyle(options) {
|
|
104
|
+
const baseColor = options.bgColor || '#007AFF';
|
|
105
|
+
|
|
106
|
+
switch (this.type) {
|
|
107
|
+
case 'filled':
|
|
108
|
+
this.bgColor = baseColor;
|
|
109
|
+
this.textColor = options.textColor || '#FFFFFF';
|
|
110
|
+
this.borderWidth = 0;
|
|
111
|
+
break;
|
|
112
|
+
|
|
113
|
+
case 'gray':
|
|
114
|
+
this.bgColor = 'rgba(120, 120, 128, 0.16)';
|
|
115
|
+
this.textColor = options.textColor || baseColor;
|
|
116
|
+
this.borderWidth = 0;
|
|
117
|
+
break;
|
|
118
|
+
|
|
119
|
+
case 'tinted':
|
|
120
|
+
this.bgColor = this.hexToRgba(baseColor, 0.2);
|
|
121
|
+
this.textColor = options.textColor || baseColor;
|
|
122
|
+
this.borderWidth = 0;
|
|
123
|
+
break;
|
|
124
|
+
|
|
125
|
+
case 'plain':
|
|
126
|
+
this.bgColor = 'transparent';
|
|
127
|
+
this.textColor = options.textColor || baseColor;
|
|
128
|
+
this.borderWidth = 0;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Retourne le rayon des coins selon la forme
|
|
135
|
+
* @returns {number} Rayon en pixels
|
|
136
|
+
* @private
|
|
137
|
+
*/
|
|
138
|
+
getBorderRadius() {
|
|
139
|
+
switch (this.shape) {
|
|
140
|
+
case 'square':
|
|
141
|
+
return 0;
|
|
142
|
+
case 'rounded':
|
|
143
|
+
default:
|
|
144
|
+
return this.platform === 'material' ? 4 : 10;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
52
148
|
/**
|
|
53
149
|
* Gère la pression sur le bouton
|
|
54
150
|
* @param {number} x - Coordonnée X
|
|
@@ -56,7 +152,6 @@ class Button extends Component {
|
|
|
56
152
|
* @private
|
|
57
153
|
*/
|
|
58
154
|
handlePress(x, y) {
|
|
59
|
-
// Créer un ripple au point de clic
|
|
60
155
|
if (this.platform === 'material') {
|
|
61
156
|
const adjustedY = y - this.framework.scrollOffset;
|
|
62
157
|
this.ripples.push({
|
|
@@ -89,7 +184,6 @@ class Button extends Component {
|
|
|
89
184
|
}
|
|
90
185
|
}
|
|
91
186
|
|
|
92
|
-
// Nettoyer les ripples terminés
|
|
93
187
|
this.ripples = this.ripples.filter(r => r.opacity > 0);
|
|
94
188
|
|
|
95
189
|
if (hasActiveRipples) {
|
|
@@ -107,31 +201,44 @@ class Button extends Component {
|
|
|
107
201
|
draw(ctx) {
|
|
108
202
|
ctx.save();
|
|
109
203
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
204
|
+
const radius = this.getBorderRadius();
|
|
205
|
+
|
|
206
|
+
// Ombre Material (elevated/filled)
|
|
207
|
+
if (this.platform === 'material' && this.elevation > 0 && !this.pressed) {
|
|
208
|
+
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
|
|
209
|
+
ctx.shadowBlur = this.elevation * 2;
|
|
210
|
+
ctx.shadowOffsetY = this.elevation;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Background
|
|
214
|
+
if (this.bgColor !== 'transparent') {
|
|
119
215
|
ctx.fillStyle = this.pressed ? this.darkenColor(this.bgColor) : this.bgColor;
|
|
120
216
|
ctx.beginPath();
|
|
121
|
-
this.roundRect(ctx, this.x, this.y, this.width, this.height,
|
|
217
|
+
this.roundRect(ctx, this.x, this.y, this.width, this.height, radius);
|
|
122
218
|
ctx.fill();
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Réinitialiser l'ombre
|
|
222
|
+
ctx.shadowColor = 'transparent';
|
|
223
|
+
ctx.shadowBlur = 0;
|
|
224
|
+
ctx.shadowOffsetY = 0;
|
|
225
|
+
|
|
226
|
+
// Bordure
|
|
227
|
+
if (this.borderWidth > 0) {
|
|
228
|
+
ctx.strokeStyle = this.borderColor;
|
|
229
|
+
ctx.lineWidth = this.borderWidth;
|
|
230
|
+
ctx.beginPath();
|
|
231
|
+
this.roundRect(ctx, this.x, this.y, this.width, this.height, radius);
|
|
232
|
+
ctx.stroke();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Ripple effect (Material)
|
|
236
|
+
if (this.platform === 'material') {
|
|
129
237
|
ctx.save();
|
|
130
238
|
ctx.beginPath();
|
|
131
|
-
this.roundRect(ctx, this.x, this.y, this.width, this.height,
|
|
239
|
+
this.roundRect(ctx, this.x, this.y, this.width, this.height, radius);
|
|
132
240
|
ctx.clip();
|
|
133
241
|
|
|
134
|
-
// Dessiner les ripples
|
|
135
242
|
for (let ripple of this.ripples) {
|
|
136
243
|
ctx.globalAlpha = ripple.opacity;
|
|
137
244
|
ctx.fillStyle = this.rippleColor;
|
|
@@ -141,17 +248,20 @@ class Button extends Component {
|
|
|
141
248
|
}
|
|
142
249
|
|
|
143
250
|
ctx.restore();
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Effet pressed pour iOS (overlay sombre)
|
|
254
|
+
if (this.platform === 'cupertino' && this.pressed && this.bgColor !== 'transparent') {
|
|
255
|
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
|
|
148
256
|
ctx.beginPath();
|
|
149
|
-
this.roundRect(ctx, this.x, this.y, this.width, this.height,
|
|
257
|
+
this.roundRect(ctx, this.x, this.y, this.width, this.height, radius);
|
|
150
258
|
ctx.fill();
|
|
151
259
|
}
|
|
152
260
|
|
|
153
261
|
// Texte
|
|
154
|
-
ctx.fillStyle = this.
|
|
262
|
+
ctx.fillStyle = this.pressed && this.platform === 'cupertino'
|
|
263
|
+
? this.darkenColor(this.textColor)
|
|
264
|
+
: this.textColor;
|
|
155
265
|
ctx.font = `${this.fontSize}px -apple-system, BlinkMacSystemFont, 'Roboto', sans-serif`;
|
|
156
266
|
ctx.textAlign = 'center';
|
|
157
267
|
ctx.textBaseline = 'middle';
|
|
@@ -162,15 +272,14 @@ class Button extends Component {
|
|
|
162
272
|
|
|
163
273
|
/**
|
|
164
274
|
* Dessine un rectangle avec coins arrondis
|
|
165
|
-
* @param {CanvasRenderingContext2D} ctx - Contexte de dessin
|
|
166
|
-
* @param {number} x - Position X
|
|
167
|
-
* @param {number} y - Position Y
|
|
168
|
-
* @param {number} width - Largeur
|
|
169
|
-
* @param {number} height - Hauteur
|
|
170
|
-
* @param {number} radius - Rayon des coins
|
|
171
275
|
* @private
|
|
172
276
|
*/
|
|
173
277
|
roundRect(ctx, x, y, width, height, radius) {
|
|
278
|
+
if (radius === 0) {
|
|
279
|
+
ctx.rect(x, y, width, height);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
174
283
|
ctx.moveTo(x + radius, y);
|
|
175
284
|
ctx.lineTo(x + width - radius, y);
|
|
176
285
|
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
|
@@ -184,19 +293,24 @@ class Button extends Component {
|
|
|
184
293
|
|
|
185
294
|
/**
|
|
186
295
|
* Assombrit une couleur
|
|
187
|
-
* @param {string} color - Couleur hexadécimale
|
|
188
|
-
* @returns {string} Couleur assombrie
|
|
189
296
|
* @private
|
|
190
297
|
*/
|
|
191
298
|
darkenColor(color) {
|
|
299
|
+
if (color === 'transparent') return 'rgba(0, 0, 0, 0.1)';
|
|
300
|
+
|
|
301
|
+
if (color.startsWith('rgba') || color.startsWith('rgb')) {
|
|
302
|
+
return color.replace(/[\d.]+\)$/g, match => {
|
|
303
|
+
const val = parseFloat(match);
|
|
304
|
+
return `${Math.max(0, val - 0.1)})`;
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
192
308
|
const rgb = this.hexToRgb(color);
|
|
193
309
|
return `rgb(${Math.max(0, rgb.r - 30)}, ${Math.max(0, rgb.g - 30)}, ${Math.max(0, rgb.b - 30)})`;
|
|
194
310
|
}
|
|
195
311
|
|
|
196
312
|
/**
|
|
197
|
-
* Convertit
|
|
198
|
-
* @param {string} hex - Couleur hexadécimale
|
|
199
|
-
* @returns {{r: number, g: number, b: number}} Objet RGB
|
|
313
|
+
* Convertit hex en RGB
|
|
200
314
|
* @private
|
|
201
315
|
*/
|
|
202
316
|
hexToRgb(hex) {
|
|
@@ -208,11 +322,17 @@ class Button extends Component {
|
|
|
208
322
|
} : { r: 0, g: 0, b: 0 };
|
|
209
323
|
}
|
|
210
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Convertit hex en RGBA avec alpha
|
|
327
|
+
* @private
|
|
328
|
+
*/
|
|
329
|
+
hexToRgba(hex, alpha) {
|
|
330
|
+
const rgb = this.hexToRgb(hex);
|
|
331
|
+
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
|
332
|
+
}
|
|
333
|
+
|
|
211
334
|
/**
|
|
212
335
|
* Vérifie si un point est dans les limites
|
|
213
|
-
* @param {number} x - Coordonnée X
|
|
214
|
-
* @param {number} y - Coordonnée Y
|
|
215
|
-
* @returns {boolean} True si le point est dans le bouton
|
|
216
336
|
*/
|
|
217
337
|
isPointInside(x, y) {
|
|
218
338
|
return x >= this.x &&
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import FAB from './FAB.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Morphing FAB - FAB qui se transforme en barre d'actions
|
|
5
|
+
* @class
|
|
6
|
+
* @extends FAB
|
|
7
|
+
* @property {boolean} isMorphed - État transformé
|
|
8
|
+
* @property {Array} actions - Actions disponibles dans la barre
|
|
9
|
+
* @property {number} morphProgress - Progression de l'animation (0-1)
|
|
10
|
+
* @property {number} targetWidth - Largeur cible en mode morphé
|
|
11
|
+
*/
|
|
12
|
+
class MorphingFAB extends FAB {
|
|
13
|
+
/**
|
|
14
|
+
* Crée une instance de MorphingFAB
|
|
15
|
+
* @param {CanvasFramework} framework - Framework parent
|
|
16
|
+
* @param {Object} [options={}] - Options de configuration
|
|
17
|
+
* @param {Array} [options.actions=[]] - Actions de la barre
|
|
18
|
+
* @param {string} [options.morphType='toolbar'] - Type: 'toolbar', 'searchbar'
|
|
19
|
+
* @example
|
|
20
|
+
* // Toolbar
|
|
21
|
+
* actions: [
|
|
22
|
+
* { icon: '🏠', label: 'Home', action: () => {...} },
|
|
23
|
+
* { icon: '⭐', label: 'Favorites', action: () => {...} },
|
|
24
|
+
* { icon: '⚙', label: 'Settings', action: () => {...} }
|
|
25
|
+
* ]
|
|
26
|
+
*
|
|
27
|
+
* // Searchbar
|
|
28
|
+
* morphType: 'searchbar'
|
|
29
|
+
*/
|
|
30
|
+
constructor(framework, options = {}) {
|
|
31
|
+
super(framework, {
|
|
32
|
+
...options,
|
|
33
|
+
icon: options.icon || '+'
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
this.actions = options.actions || [];
|
|
37
|
+
this.morphType = options.morphType || 'toolbar';
|
|
38
|
+
this.isMorphed = false;
|
|
39
|
+
this.morphProgress = 0;
|
|
40
|
+
|
|
41
|
+
// Dimensions
|
|
42
|
+
this.originalWidth = this.width;
|
|
43
|
+
this.originalHeight = this.height;
|
|
44
|
+
this.originalX = this.x;
|
|
45
|
+
this.originalY = this.y;
|
|
46
|
+
|
|
47
|
+
// Calculer la largeur cible selon le type
|
|
48
|
+
if (this.morphType === 'searchbar') {
|
|
49
|
+
this.targetWidth = Math.min(framework.width - 32, 400);
|
|
50
|
+
this.targetHeight = 56;
|
|
51
|
+
this.targetX = (framework.width - this.targetWidth) / 2;
|
|
52
|
+
this.targetY = 16;
|
|
53
|
+
} else {
|
|
54
|
+
// toolbar
|
|
55
|
+
this.targetWidth = Math.min(framework.width - 32, this.actions.length * 80 + 32);
|
|
56
|
+
this.targetHeight = 56;
|
|
57
|
+
this.targetX = (framework.width - this.targetWidth) / 2;
|
|
58
|
+
this.targetY = framework.height - 80;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// État des boutons d'actions
|
|
62
|
+
this.actionButtons = this.actions.map((action, index) => ({
|
|
63
|
+
...action,
|
|
64
|
+
x: 0,
|
|
65
|
+
y: 0,
|
|
66
|
+
width: 60,
|
|
67
|
+
height: 48,
|
|
68
|
+
alpha: 0,
|
|
69
|
+
pressed: false
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
// État de la searchbar
|
|
73
|
+
this.searchText = '';
|
|
74
|
+
this.searchFocused = false;
|
|
75
|
+
|
|
76
|
+
// Input caché pour la searchbar
|
|
77
|
+
if (this.morphType === 'searchbar') {
|
|
78
|
+
this.setupHiddenInput();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Bind
|
|
82
|
+
this.onPress = this.handlePress.bind(this);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Configure l'input HTML caché pour la searchbar
|
|
87
|
+
* @private
|
|
88
|
+
*/
|
|
89
|
+
setupHiddenInput() {
|
|
90
|
+
this.hiddenInput = document.createElement('input');
|
|
91
|
+
this.hiddenInput.style.position = 'fixed';
|
|
92
|
+
this.hiddenInput.style.opacity = '0';
|
|
93
|
+
this.hiddenInput.style.pointerEvents = 'none';
|
|
94
|
+
this.hiddenInput.style.top = '-100px';
|
|
95
|
+
document.body.appendChild(this.hiddenInput);
|
|
96
|
+
|
|
97
|
+
this.hiddenInput.addEventListener('input', (e) => {
|
|
98
|
+
if (this.searchFocused) {
|
|
99
|
+
this.searchText = e.target.value;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
this.hiddenInput.addEventListener('blur', () => {
|
|
104
|
+
this.searchFocused = false;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Gère la pression
|
|
110
|
+
*/
|
|
111
|
+
handlePress(x, y) {
|
|
112
|
+
const adjustedY = y - this.framework.scrollOffset;
|
|
113
|
+
|
|
114
|
+
if (this.isMorphed) {
|
|
115
|
+
// Mode morphé
|
|
116
|
+
if (this.morphType === 'searchbar') {
|
|
117
|
+
// Zone de l'input searchbar
|
|
118
|
+
const searchInputX = this.x + 48;
|
|
119
|
+
const searchInputWidth = this.width - 96;
|
|
120
|
+
|
|
121
|
+
if (x >= searchInputX && x <= searchInputX + searchInputWidth &&
|
|
122
|
+
adjustedY >= this.y && adjustedY <= this.y + this.height) {
|
|
123
|
+
// Clic sur l'input - activer le focus
|
|
124
|
+
this.searchFocused = true;
|
|
125
|
+
if (this.hiddenInput) {
|
|
126
|
+
this.hiddenInput.value = this.searchText;
|
|
127
|
+
this.hiddenInput.style.top = `${this.y}px`;
|
|
128
|
+
this.hiddenInput.focus();
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Bouton fermer (X)
|
|
134
|
+
const closeX = this.x + this.width - 40;
|
|
135
|
+
if (x >= closeX && x <= closeX + 40 &&
|
|
136
|
+
adjustedY >= this.y && adjustedY <= this.y + this.height) {
|
|
137
|
+
this.searchText = '';
|
|
138
|
+
this.toggle();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
// Toolbar - vérifier les actions
|
|
143
|
+
for (let btn of this.actionButtons) {
|
|
144
|
+
if (x >= btn.x && x <= btn.x + btn.width &&
|
|
145
|
+
adjustedY >= btn.y && adjustedY <= btn.y + btn.height) {
|
|
146
|
+
btn.pressed = true;
|
|
147
|
+
setTimeout(() => {
|
|
148
|
+
btn.pressed = false;
|
|
149
|
+
if (btn.action) btn.action();
|
|
150
|
+
}, 150);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Clic en dehors -> fermer
|
|
157
|
+
if (!this.isPointInside(x, y)) {
|
|
158
|
+
this.toggle();
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
// Mode normal - ouvrir
|
|
162
|
+
this.toggle();
|
|
163
|
+
super.handlePress(x, y);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Toggle entre FAB et barre
|
|
169
|
+
*/
|
|
170
|
+
toggle() {
|
|
171
|
+
this.isMorphed = !this.isMorphed;
|
|
172
|
+
|
|
173
|
+
// Si on ferme et que c'est une searchbar, nettoyer l'input
|
|
174
|
+
if (!this.isMorphed && this.morphType === 'searchbar') {
|
|
175
|
+
this.searchText = '';
|
|
176
|
+
this.searchFocused = false;
|
|
177
|
+
if (this.hiddenInput) {
|
|
178
|
+
this.hiddenInput.blur();
|
|
179
|
+
this.hiddenInput.remove();
|
|
180
|
+
this.hiddenInput = null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Si on ouvre une searchbar, recréer l'input
|
|
185
|
+
if (this.isMorphed && this.morphType === 'searchbar' && !this.hiddenInput) {
|
|
186
|
+
this.setupHiddenInput();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
this.animate();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Anime la transformation
|
|
194
|
+
* @private
|
|
195
|
+
*/
|
|
196
|
+
animate() {
|
|
197
|
+
const startTime = Date.now();
|
|
198
|
+
const duration = 400;
|
|
199
|
+
|
|
200
|
+
const step = () => {
|
|
201
|
+
const elapsed = Date.now() - startTime;
|
|
202
|
+
const progress = Math.min(elapsed / duration, 1);
|
|
203
|
+
|
|
204
|
+
// Easing out cubic
|
|
205
|
+
const eased = 1 - Math.pow(1 - progress, 3);
|
|
206
|
+
|
|
207
|
+
this.morphProgress = this.isMorphed ? eased : 1 - eased;
|
|
208
|
+
|
|
209
|
+
// Interpoler les dimensions
|
|
210
|
+
this.width = this.lerp(this.originalWidth, this.targetWidth, this.morphProgress);
|
|
211
|
+
this.height = this.lerp(this.originalHeight, this.targetHeight, this.morphProgress);
|
|
212
|
+
this.x = this.lerp(this.originalX, this.targetX, this.morphProgress);
|
|
213
|
+
this.y = this.lerp(this.originalY, this.targetY, this.morphProgress);
|
|
214
|
+
|
|
215
|
+
// Mettre à jour les positions des actions
|
|
216
|
+
if (this.morphType === 'toolbar') {
|
|
217
|
+
this.updateActionPositions();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (progress < 1) {
|
|
221
|
+
requestAnimationFrame(step);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
step();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Met à jour les positions des boutons d'action
|
|
230
|
+
* @private
|
|
231
|
+
*/
|
|
232
|
+
updateActionPositions() {
|
|
233
|
+
const spacing = this.targetWidth / (this.actionButtons.length + 1);
|
|
234
|
+
|
|
235
|
+
this.actionButtons.forEach((btn, index) => {
|
|
236
|
+
btn.x = this.x + spacing * (index + 1) - btn.width / 2;
|
|
237
|
+
btn.y = this.y + (this.height - btn.height) / 2;
|
|
238
|
+
btn.alpha = this.morphProgress;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Interpolation linéaire
|
|
244
|
+
* @private
|
|
245
|
+
*/
|
|
246
|
+
lerp(start, end, t) {
|
|
247
|
+
return start + (end - start) * t;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Dessine le Morphing FAB
|
|
252
|
+
*/
|
|
253
|
+
draw(ctx) {
|
|
254
|
+
ctx.save();
|
|
255
|
+
|
|
256
|
+
if (this.isMorphed || this.morphProgress > 0) {
|
|
257
|
+
this.drawMorphed(ctx);
|
|
258
|
+
} else {
|
|
259
|
+
super.draw(ctx);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
ctx.restore();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Dessine l'état morphé
|
|
267
|
+
* @private
|
|
268
|
+
*/
|
|
269
|
+
drawMorphed(ctx) {
|
|
270
|
+
// Ombre
|
|
271
|
+
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
|
|
272
|
+
ctx.shadowBlur = 12;
|
|
273
|
+
ctx.shadowOffsetY = 4;
|
|
274
|
+
|
|
275
|
+
// Background de la barre
|
|
276
|
+
ctx.fillStyle = this.bgColor;
|
|
277
|
+
ctx.beginPath();
|
|
278
|
+
const radius = this.lerp(this.borderRadius, 28, this.morphProgress);
|
|
279
|
+
this.roundRect(ctx, this.x, this.y, this.width, this.height, radius);
|
|
280
|
+
ctx.fill();
|
|
281
|
+
|
|
282
|
+
ctx.shadowColor = 'transparent';
|
|
283
|
+
ctx.shadowBlur = 0;
|
|
284
|
+
ctx.shadowOffsetY = 0;
|
|
285
|
+
|
|
286
|
+
if (this.morphType === 'searchbar') {
|
|
287
|
+
this.drawSearchBar(ctx);
|
|
288
|
+
} else {
|
|
289
|
+
this.drawToolbar(ctx);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Dessine la toolbar
|
|
295
|
+
* @private
|
|
296
|
+
*/
|
|
297
|
+
drawToolbar(ctx) {
|
|
298
|
+
// Dessiner les actions
|
|
299
|
+
for (let btn of this.actionButtons) {
|
|
300
|
+
if (btn.alpha > 0.01) {
|
|
301
|
+
ctx.save();
|
|
302
|
+
ctx.globalAlpha = btn.alpha;
|
|
303
|
+
|
|
304
|
+
// Highlight si pressed
|
|
305
|
+
if (btn.pressed) {
|
|
306
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
|
|
307
|
+
ctx.beginPath();
|
|
308
|
+
ctx.arc(btn.x + btn.width / 2, btn.y + btn.height / 2, 24, 0, Math.PI * 2);
|
|
309
|
+
ctx.fill();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Icône
|
|
313
|
+
ctx.fillStyle = this.iconColor;
|
|
314
|
+
ctx.font = 'bold 20px sans-serif';
|
|
315
|
+
ctx.textAlign = 'center';
|
|
316
|
+
ctx.textBaseline = 'middle';
|
|
317
|
+
ctx.fillText(btn.icon, btn.x + btn.width / 2, btn.y + btn.height / 2 - 8);
|
|
318
|
+
|
|
319
|
+
// Label
|
|
320
|
+
if (btn.label) {
|
|
321
|
+
ctx.font = '11px -apple-system, sans-serif';
|
|
322
|
+
ctx.fillText(btn.label, btn.x + btn.width / 2, btn.y + btn.height / 2 + 12);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
ctx.restore();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Dessine la search bar
|
|
332
|
+
* @private
|
|
333
|
+
*/
|
|
334
|
+
drawSearchBar(ctx) {
|
|
335
|
+
ctx.save();
|
|
336
|
+
ctx.globalAlpha = this.morphProgress;
|
|
337
|
+
|
|
338
|
+
// Icône de recherche
|
|
339
|
+
ctx.fillStyle = this.iconColor;
|
|
340
|
+
ctx.font = 'bold 20px sans-serif';
|
|
341
|
+
ctx.textAlign = 'left';
|
|
342
|
+
ctx.textBaseline = 'middle';
|
|
343
|
+
ctx.fillText('🔍', this.x + 16, this.y + this.height / 2);
|
|
344
|
+
|
|
345
|
+
// Curseur clignotant si focus
|
|
346
|
+
let showCursor = false;
|
|
347
|
+
if (this.searchFocused) {
|
|
348
|
+
showCursor = Math.floor(Date.now() / 500) % 2 === 0;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Placeholder ou texte
|
|
352
|
+
ctx.font = '16px -apple-system, sans-serif';
|
|
353
|
+
ctx.fillStyle = this.searchText ? this.iconColor : 'rgba(255, 255, 255, 0.6)';
|
|
354
|
+
const displayText = this.searchText || 'Search...';
|
|
355
|
+
ctx.fillText(displayText, this.x + 48, this.y + this.height / 2);
|
|
356
|
+
|
|
357
|
+
// Curseur
|
|
358
|
+
if (showCursor && this.searchText) {
|
|
359
|
+
const textWidth = ctx.measureText(this.searchText).width;
|
|
360
|
+
ctx.fillStyle = this.iconColor;
|
|
361
|
+
ctx.fillRect(this.x + 48 + textWidth + 2, this.y + this.height / 2 - 10, 2, 20);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Bouton fermer
|
|
365
|
+
if (this.searchText || this.isMorphed) {
|
|
366
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
|
|
367
|
+
ctx.font = 'bold 18px sans-serif';
|
|
368
|
+
ctx.textAlign = 'right';
|
|
369
|
+
ctx.fillText('✕', this.x + this.width - 16, this.y + this.height / 2);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
ctx.restore();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Dessine un rectangle arrondi
|
|
377
|
+
* @private
|
|
378
|
+
*/
|
|
379
|
+
roundRect(ctx, x, y, width, height, radius) {
|
|
380
|
+
ctx.beginPath();
|
|
381
|
+
ctx.moveTo(x + radius, y);
|
|
382
|
+
ctx.lineTo(x + width - radius, y);
|
|
383
|
+
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
|
384
|
+
ctx.lineTo(x + width, y + height - radius);
|
|
385
|
+
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
|
386
|
+
ctx.lineTo(x + radius, y + height);
|
|
387
|
+
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
|
388
|
+
ctx.lineTo(x, y + radius);
|
|
389
|
+
ctx.quadraticCurveTo(x, y, x + radius, y);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Vérifie si un point est dans le composant
|
|
394
|
+
*/
|
|
395
|
+
isPointInside(x, y) {
|
|
396
|
+
const adjustedY = y - this.framework.scrollOffset;
|
|
397
|
+
return x >= this.x && x <= this.x + this.width &&
|
|
398
|
+
adjustedY >= this.y && adjustedY <= this.y + this.height;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Gère l'input texte (pour searchbar)
|
|
403
|
+
* @param {string} char - Caractère à ajouter
|
|
404
|
+
*/
|
|
405
|
+
addChar(char) {
|
|
406
|
+
if (this.morphType === 'searchbar' && this.isMorphed) {
|
|
407
|
+
this.searchText += char;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Efface un caractère
|
|
413
|
+
*/
|
|
414
|
+
backspace() {
|
|
415
|
+
if (this.morphType === 'searchbar' && this.isMorphed) {
|
|
416
|
+
this.searchText = this.searchText.slice(0, -1);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Réinitialise la recherche
|
|
422
|
+
*/
|
|
423
|
+
clearSearch() {
|
|
424
|
+
this.searchText = '';
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export default MorphingFAB;
|
|
@@ -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;
|
package/core/CanvasFramework.js
CHANGED
|
@@ -6,6 +6,8 @@ import Text from '../components/Text.js';
|
|
|
6
6
|
import View from '../components/View.js';
|
|
7
7
|
import Card from '../components/Card.js';
|
|
8
8
|
import FAB from '../components/FAB.js';
|
|
9
|
+
import SpeedDialFAB from '../components/SpeedDialFAB.js';
|
|
10
|
+
import MorphingFAB from '../components/MorphingFAB.js';
|
|
9
11
|
import CircularProgress from '../components/CircularProgress.js';
|
|
10
12
|
import ImageComponent from '../components/ImageComponent.js';
|
|
11
13
|
import DatePicker from '../components/DatePicker.js';
|
package/index.js
CHANGED
|
@@ -13,6 +13,8 @@ export { default as Text } from './components/Text.js';
|
|
|
13
13
|
export { default as View } from './components/View.js';
|
|
14
14
|
export { default as Card } from './components/Card.js';
|
|
15
15
|
export { default as FAB } from './components/FAB.js';
|
|
16
|
+
export { default as SpeedDialFAB } from './components/SpeedDialFAB.js';
|
|
17
|
+
export { default as MorphingFAB } from './components/MorphingFAB.js';
|
|
16
18
|
export { default as CircularProgress } from './components/CircularProgress.js';
|
|
17
19
|
export { default as ImageComponent } from './components/ImageComponent.js';
|
|
18
20
|
export { default as DatePicker } from './components/DatePicker.js';
|
|
@@ -91,7 +93,7 @@ export { default as FeatureFlags } from './manager/FeatureFlags.js';
|
|
|
91
93
|
|
|
92
94
|
// Version du framework
|
|
93
95
|
|
|
94
|
-
export const VERSION = '0.3.
|
|
96
|
+
export const VERSION = '0.3.9';
|
|
95
97
|
|
|
96
98
|
|
|
97
99
|
|