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.
@@ -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
- * @property {string} text - Texte du bouton
8
- * @property {number} fontSize - Taille de la police
9
- * @property {string} platform - Plateforme
10
- * @property {string} bgColor - Couleur de fond
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.bgColor] - Couleur de fond (auto selon platform)
24
- * @param {string} [options.textColor] - Couleur du texte (auto selon platform)
25
- * @param {number} [options.elevation=2] - Élévation (Material)
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
- // Couleurs selon la plateforme
32
+ // Définir le type de bouton selon la plateforme
34
33
  if (this.platform === 'material') {
35
- this.bgColor = options.bgColor || '#6200EE';
36
- this.textColor = options.textColor || '#FFFFFF';
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.bgColor = options.bgColor || '#007AFF';
41
- this.textColor = options.textColor || '#FFFFFF';
42
- this.borderRadius = 10;
37
+ this.type = options.type || 'filled';
38
+ this.setupCupertinoStyle(options);
43
39
  }
44
40
 
45
- // Effet Ripple
41
+ // Effets ripple (Material uniquement)
46
42
  this.ripples = [];
47
43
 
48
- // Bind des méthodes
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
- if (this.platform === 'material') {
111
- // Material Design
112
- // Ombre (elevation)
113
- if (this.elevation > 0 && !this.pressed) {
114
- ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
115
- ctx.shadowBlur = this.elevation * 2;
116
- ctx.shadowOffsetY = this.elevation;
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, 4);
217
+ this.roundRect(ctx, this.x, this.y, this.width, this.height, radius);
122
218
  ctx.fill();
123
-
124
- ctx.shadowColor = 'transparent';
125
- ctx.shadowBlur = 0;
126
- ctx.shadowOffsetY = 0;
127
-
128
- // Clipping pour les ripples
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, 4);
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
- } else {
146
- // Cupertino (iOS)
147
- ctx.fillStyle = this.pressed ? this.darkenColor(this.bgColor) : this.bgColor;
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, this.borderRadius);
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.textColor;
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 une couleur hex en RGB
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;
@@ -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.8';
96
+ export const VERSION = '0.3.9';
95
97
 
96
98
 
97
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvasframework",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Canvas-based cross-platform UI framework (Material & Cupertino)",
5
5
  "type": "module",
6
6
  "main": "./index.js",