canvasframework 0.6.3 → 0.7.1

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,357 +1,310 @@
1
1
  import Component from '../core/Component.js';
2
+ import { roundRect, hexToRgb, hexToRgba, darkenColor } from '../core/CanvasUtils.js';
2
3
 
3
4
  /**
4
- * Bouton cliquable avec variantes Material et Cupertino
5
- * @class
6
- * @extends Component
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)
5
+ * Bouton cliquable avec variantes Material et Cupertino.
6
+ *
7
+ * Types Material : 'filled' | 'outlined' | 'text' | 'elevated' | 'tonal'
8
+ * Types Cupertino : 'filled' | 'gray' | 'tinted' | 'bordered' | 'plain'
9
+ * Shapes : 'rounded' | 'square' | 'pill'
11
10
  */
12
11
  class Button extends Component {
13
12
  /**
14
- * Crée une instance de Button
15
- * @param {CanvasFramework} framework - Framework parent
16
- * @param {Object} [options={}] - Options de configuration
17
- * @param {string} [options.text='Button'] - Texte
18
- * @param {number} [options.fontSize=16] - Taille de police
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)
13
+ * @param {CanvasFramework} framework
14
+ * @param {Object} [options={}]
15
+ * @param {string} [options.text='Button']
16
+ * @param {number} [options.fontSize=16]
17
+ * @param {string} [options.type] - Variante selon la plateforme
18
+ * @param {string} [options.shape='rounded'] - 'rounded' | 'square' | 'pill'
19
+ * @param {string} [options.bgColor]
20
+ * @param {string} [options.textColor]
21
+ * @param {number} [options.elevation=2]
22
+ * @param {boolean} [options.disabled=false]
24
23
  */
25
24
  constructor(framework, options = {}) {
26
25
  super(framework, options);
27
- this.text = options.text || 'Button';
28
- this.fontSize = options.fontSize || 16;
29
- this.platform = framework.platform;
30
- this.shape = options.shape || 'rounded';
31
-
32
- // Définir le type de bouton selon la plateforme
26
+
27
+ this.text = options.text || 'Button';
28
+ this.fontSize = options.fontSize || 16;
29
+ this.platform = framework.platform;
30
+ this.shape = options.shape || 'rounded';
31
+ this.disabled = options.disabled || false;
32
+
33
33
  if (this.platform === 'material') {
34
34
  this.type = options.type || 'filled';
35
- this.setupMaterialStyle(options);
35
+ this._setupMaterialStyle(options);
36
36
  } else {
37
37
  this.type = options.type || 'filled';
38
- this.setupCupertinoStyle(options);
38
+ this._setupCupertinoStyle(options);
39
39
  }
40
-
41
- // Effets ripple (Material uniquement)
40
+
41
+ // Effet ripple (Material uniquement)
42
42
  this.ripples = [];
43
-
44
- // Bind
45
- this.onPress = this.handlePress.bind(this);
43
+ this._rafId = null;
44
+
45
+ this.onPress = this._handlePress.bind(this);
46
46
  }
47
-
48
- /**
49
- * Configure le style Material Design
50
- * @private
51
- */
52
- setupMaterialStyle(options) {
53
- const baseColor = options.bgColor || '#6200EE';
47
+
48
+ // ─────────────────────────────────────────
49
+ // SETUP STYLES
50
+ // ─────────────────────────────────────────
51
+
52
+ /** @private */
53
+ _setupMaterialStyle(options) {
54
+ const base = options.bgColor || '#6200EE';
54
55
  this.elevation = options.elevation || 2;
55
-
56
+
56
57
  switch (this.type) {
57
58
  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)';
59
+ this.bgColor = base;
60
+ this.textColor = options.textColor || '#FFFFFF';
61
+ this.borderWidth = 0;
62
+ this.rippleColor = 'rgba(255,255,255,0.3)';
62
63
  break;
63
-
64
+
64
65
  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;
66
+ this.bgColor = 'transparent';
67
+ this.textColor = options.textColor || base;
68
+ this.borderColor = base;
69
+ this.borderWidth = 1;
70
+ this.rippleColor = hexToRgba(base, 0.2);
71
+ this.elevation = 0;
71
72
  break;
72
-
73
+
73
74
  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;
75
+ this.bgColor = 'transparent';
76
+ this.textColor = options.textColor || base;
77
+ this.borderWidth = 0;
78
+ this.rippleColor = hexToRgba(base, 0.2);
79
+ this.elevation = 0;
79
80
  break;
80
-
81
+
81
82
  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;
83
+ this.bgColor = options.bgColor || '#FFFFFF';
84
+ this.textColor = options.textColor || base;
85
+ this.borderWidth = 0;
86
+ this.rippleColor = hexToRgba(base, 0.2);
87
+ this.elevation = options.elevation || 4;
87
88
  break;
88
-
89
+
89
90
  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
- default :
98
- this.bgColor = options.bgColor || '#FFFFFF';
99
- this.textColor = options.textColor || baseColor;
100
- this.borderWidth = 0;
101
- this.rippleColor = this.hexToRgba(baseColor, 0.2);
102
- this.elevation = options.elevation || 4;
91
+ this.bgColor = hexToRgba(base, 0.3);
92
+ this.textColor = options.textColor || base;
93
+ this.borderWidth = 0;
94
+ this.rippleColor = hexToRgba(base, 0.3);
95
+ this.elevation = 0;
103
96
  break;
97
+
98
+ default:
99
+ this.bgColor = options.bgColor || '#FFFFFF';
100
+ this.textColor = options.textColor || base;
101
+ this.borderWidth = 0;
102
+ this.rippleColor = hexToRgba(base, 0.2);
103
+ this.elevation = options.elevation || 4;
104
104
  }
105
105
  }
106
-
107
- /**
108
- * Configure le style Cupertino (iOS)
109
- * @private
110
- */
111
- setupCupertinoStyle(options) {
112
- const baseColor = options.bgColor || '#007AFF';
113
-
106
+
107
+ /** @private */
108
+ _setupCupertinoStyle(options) {
109
+ const base = options.bgColor || '#007AFF';
110
+
114
111
  switch (this.type) {
115
112
  case 'filled':
116
- this.bgColor = baseColor;
117
- this.textColor = options.textColor || '#FFFFFF';
113
+ this.bgColor = base;
114
+ this.textColor = options.textColor || '#FFFFFF';
118
115
  this.borderWidth = 0;
119
116
  break;
120
-
117
+
121
118
  case 'gray':
122
- this.bgColor = 'rgba(120, 120, 128, 0.16)';
123
- this.textColor = options.textColor || baseColor;
119
+ this.bgColor = 'rgba(120,120,128,0.16)';
120
+ this.textColor = options.textColor || base;
124
121
  this.borderWidth = 0;
125
122
  break;
126
-
123
+
127
124
  case 'tinted':
128
- this.bgColor = this.hexToRgba(baseColor, 0.2);
129
- this.textColor = options.textColor || baseColor;
125
+ this.bgColor = hexToRgba(base, 0.2);
126
+ this.textColor = options.textColor || base;
130
127
  this.borderWidth = 0;
131
128
  break;
132
-
133
- case 'plain':
134
- this.bgColor = 'transparent';
135
- this.textColor = options.textColor || baseColor;
136
- this.borderWidth = 0;
129
+
130
+ case 'bordered':
131
+ this.bgColor = 'transparent';
132
+ this.textColor = options.textColor || base;
133
+ this.borderColor = base;
134
+ this.borderWidth = 1;
137
135
  break;
138
-
139
- default :
140
- this.bgColor = 'transparent';
141
- this.textColor = options.textColor || baseColor;
136
+
137
+ case 'plain':
138
+ default:
139
+ this.bgColor = 'transparent';
140
+ this.textColor = options.textColor || base;
142
141
  this.borderWidth = 0;
143
- break;
144
142
  }
145
143
  }
146
-
147
- /**
148
- * Retourne le rayon des coins selon la forme
149
- * @returns {number} Rayon en pixels
150
- * @private
151
- */
152
- getBorderRadius() {
153
- switch (this.shape) {
154
- case 'square':
155
- return 0;
156
- case 'rounded':
157
- default:
158
- return this.platform === 'material' ? 4 : 10;
159
- }
144
+
145
+ // ─────────────────────────────────────────
146
+ // HELPERS
147
+ // ─────────────────────────────────────────
148
+
149
+ /** @private */
150
+ _getBorderRadius() {
151
+ if (this.shape === 'square') return 0;
152
+ if (this.shape === 'pill') return this.height / 2;
153
+ // 'rounded' (défaut)
154
+ return this.platform === 'material' ? 4 : 10;
160
155
  }
161
-
162
- /**
163
- * Gère la pression sur le bouton
164
- * @param {number} x - Coordonnée X
165
- * @param {number} y - Coordonnée Y
166
- * @private
167
- */
168
- handlePress(x, y) {
156
+
157
+ // ─────────────────────────────────────────
158
+ // INTERACTIONS
159
+ // ─────────────────────────────────────────
160
+
161
+ /** @private */
162
+ _handlePress(x, y) {
163
+ if (this.disabled) return;
164
+
169
165
  if (this.platform === 'material') {
170
- const adjustedY = y - this.framework.scrollOffset;
166
+ const adjY = y - (this.framework.scrollOffset || 0);
171
167
  this.ripples.push({
172
168
  x: x - this.x,
173
- y: adjustedY - this.y,
169
+ y: adjY - this.y,
174
170
  radius: 0,
175
171
  maxRadius: Math.max(this.width, this.height) * 1.5,
176
- opacity: 1
172
+ opacity: 1,
177
173
  });
178
- this.animateRipple();
174
+ this._animateRipple();
179
175
  }
180
176
  }
181
-
182
- /**
183
- * Anime les effets ripple
184
- * @private
185
- */
186
- animateRipple() {
187
- const animate = () => {
188
- for (let ripple of this.ripples) {
189
- ripple.radius += ripple.maxRadius / 15;
190
- ripple.opacity -= 0.05; // diminue progressivement
191
- }
192
177
 
193
- // Supprime les ripples complètement invisibles
194
- this.ripples = this.ripples.filter(r => r.opacity > 0);
178
+ /** @private */
179
+ _animateRipple() {
180
+ if (this._rafId) return; // déjà en cours
195
181
 
196
- // Redessine le bouton après mise à jour (important!)
197
- if (this.framework && this.framework.redraw) {
198
- this.framework.redraw();
182
+ const animate = () => {
183
+ if (this._destroyed) { this._rafId = null; return; }
184
+
185
+ for (const r of this.ripples) {
186
+ r.radius += r.maxRadius / 15;
187
+ r.opacity -= 0.05;
199
188
  }
189
+ this.ripples = this.ripples.filter((r) => r.opacity > 0);
190
+
191
+ // Utilise markDirty plutôt que de redessiner tout le canvas
192
+ this.markDirty();
200
193
 
201
- // Tant qu'il reste des ripples visibles, continue l'animation
202
194
  if (this.ripples.length > 0) {
203
- requestAnimationFrame(animate);
195
+ this._rafId = requestAnimationFrame(animate);
196
+ } else {
197
+ this._rafId = null;
204
198
  }
205
199
  };
206
200
 
207
- animate();
201
+ this._rafId = requestAnimationFrame(animate);
208
202
  }
209
203
 
210
- /**
211
- * Dessine le bouton
212
- * @param {CanvasRenderingContext2D} ctx - Contexte de dessin
213
- */
204
+ // ─────────────────────────────────────────
205
+ // DESSIN
206
+ // ─────────────────────────────────────────
207
+
214
208
  draw(ctx) {
215
209
  ctx.save();
216
-
217
- const radius = this.getBorderRadius();
218
-
219
- // Ombre Material (elevated/filled)
220
- if (this.platform === 'material' && this.elevation > 0 && !this.pressed) {
221
- ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
222
- ctx.shadowBlur = this.elevation * 2;
210
+
211
+ const radius = this._getBorderRadius();
212
+ const alpha = this.disabled ? 0.38 : 1;
213
+ ctx.globalAlpha = alpha;
214
+
215
+ // Ombre Material (elevated / filled non pressé)
216
+ if (this.platform === 'material' && (this.elevation || 0) > 0 && !this.pressed) {
217
+ ctx.shadowColor = 'rgba(0,0,0,0.3)';
218
+ ctx.shadowBlur = this.elevation * 2;
223
219
  ctx.shadowOffsetY = this.elevation;
224
220
  }
225
-
226
- // Background
221
+
222
+ // Fond
227
223
  if (this.bgColor !== 'transparent') {
228
- ctx.fillStyle = this.pressed ? this.darkenColor(this.bgColor) : this.bgColor;
224
+ ctx.fillStyle = this.pressed ? darkenColor(this.bgColor) : this.bgColor;
229
225
  ctx.beginPath();
230
- this.roundRect(ctx, this.x, this.y, this.width, this.height, radius);
226
+ roundRect(ctx, this.x, this.y, this.width, this.height, radius);
231
227
  ctx.fill();
232
228
  }
233
-
229
+
234
230
  // Réinitialiser l'ombre
235
- ctx.shadowColor = 'transparent';
236
- ctx.shadowBlur = 0;
231
+ ctx.shadowColor = 'transparent';
232
+ ctx.shadowBlur = 0;
237
233
  ctx.shadowOffsetY = 0;
238
-
234
+
239
235
  // Bordure
240
- if (this.borderWidth > 0) {
236
+ if ((this.borderWidth || 0) > 0) {
241
237
  ctx.strokeStyle = this.borderColor;
242
- ctx.lineWidth = this.borderWidth;
238
+ ctx.lineWidth = this.borderWidth;
243
239
  ctx.beginPath();
244
- this.roundRect(ctx, this.x, this.y, this.width, this.height, radius);
240
+ roundRect(ctx, this.x, this.y, this.width, this.height, radius);
245
241
  ctx.stroke();
246
242
  }
247
-
248
- // Ripple effect (Material)
249
- if (this.platform === 'material') {
243
+
244
+ // Ripple (Material)
245
+ if (this.platform === 'material' && this.ripples.length > 0) {
250
246
  ctx.save();
251
247
  ctx.beginPath();
252
- this.roundRect(ctx, this.x, this.y, this.width, this.height, radius);
248
+ roundRect(ctx, this.x, this.y, this.width, this.height, radius);
253
249
  ctx.clip();
254
-
255
- for (let ripple of this.ripples) {
256
- ctx.globalAlpha = ripple.opacity;
257
- ctx.fillStyle = this.rippleColor;
250
+
251
+ for (const r of this.ripples) {
252
+ ctx.globalAlpha = r.opacity * alpha;
253
+ ctx.fillStyle = this.rippleColor;
258
254
  ctx.beginPath();
259
- ctx.arc(this.x + ripple.x, this.y + ripple.y, ripple.radius, 0, Math.PI * 2);
255
+ ctx.arc(this.x + r.x, this.y + r.y, r.radius, 0, Math.PI * 2);
260
256
  ctx.fill();
261
257
  }
262
-
263
258
  ctx.restore();
264
259
  }
265
-
266
- // Effet pressed pour iOS (overlay sombre)
260
+
261
+ // Overlay pressé (iOS)
267
262
  if (this.platform === 'cupertino' && this.pressed && this.bgColor !== 'transparent') {
268
- ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
263
+ ctx.fillStyle = 'rgba(0,0,0,0.1)';
269
264
  ctx.beginPath();
270
- this.roundRect(ctx, this.x, this.y, this.width, this.height, radius);
265
+ roundRect(ctx, this.x, this.y, this.width, this.height, radius);
271
266
  ctx.fill();
272
267
  }
273
-
268
+
274
269
  // Texte
275
- ctx.fillStyle = this.pressed && this.platform === 'cupertino'
276
- ? this.darkenColor(this.textColor)
277
- : this.textColor;
278
- ctx.font = `${this.fontSize}px -apple-system, BlinkMacSystemFont, 'Roboto', sans-serif`;
279
- ctx.textAlign = 'center';
270
+ ctx.globalAlpha = alpha;
271
+ ctx.fillStyle =
272
+ this.pressed && this.platform === 'cupertino'
273
+ ? darkenColor(this.textColor)
274
+ : this.textColor;
275
+ ctx.font = `${this.fontSize}px -apple-system, BlinkMacSystemFont, 'Roboto', sans-serif`;
276
+ ctx.textAlign = 'center';
280
277
  ctx.textBaseline = 'middle';
281
278
  ctx.fillText(this.text, this.x + this.width / 2, this.y + this.height / 2);
282
-
283
- ctx.restore();
284
- }
285
279
 
286
- /**
287
- * Dessine un rectangle avec coins arrondis
288
- * @private
289
- */
290
- roundRect(ctx, x, y, width, height, radius) {
291
- if (radius === 0) {
292
- ctx.rect(x, y, width, height);
293
- return;
294
- }
295
-
296
- ctx.moveTo(x + radius, y);
297
- ctx.lineTo(x + width - radius, y);
298
- ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
299
- ctx.lineTo(x + width, y + height - radius);
300
- ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
301
- ctx.lineTo(x + radius, y + height);
302
- ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
303
- ctx.lineTo(x, y + radius);
304
- ctx.quadraticCurveTo(x, y, x + radius, y);
280
+ ctx.restore();
305
281
  }
306
282
 
307
- /**
308
- * Assombrit une couleur
309
- * @private
310
- */
311
- darkenColor(color) {
312
- if (color === 'transparent') return 'rgba(0, 0, 0, 0.1)';
313
-
314
- if (color.startsWith('rgba') || color.startsWith('rgb')) {
315
- return color.replace(/[\d.]+\)$/g, match => {
316
- const val = parseFloat(match);
317
- return `${Math.max(0, val - 0.1)})`;
318
- });
319
- }
320
-
321
- const rgb = this.hexToRgb(color);
322
- return `rgb(${Math.max(0, rgb.r - 30)}, ${Math.max(0, rgb.g - 30)}, ${Math.max(0, rgb.b - 30)})`;
323
- }
283
+ // ─────────────────────────────────────────
284
+ // HIT TEST
285
+ // ─────────────────────────────────────────
324
286
 
325
- /**
326
- * Convertit hex en RGB
327
- * @private
328
- */
329
- hexToRgb(hex) {
330
- const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
331
- return result ? {
332
- r: parseInt(result[1], 16),
333
- g: parseInt(result[2], 16),
334
- b: parseInt(result[3], 16)
335
- } : { r: 0, g: 0, b: 0 };
287
+ isPointInside(x, y) {
288
+ if (this.disabled) return false;
289
+ return (
290
+ x >= this.x &&
291
+ x <= this.x + this.width &&
292
+ y >= this.y &&
293
+ y <= this.y + this.height
294
+ );
336
295
  }
337
296
 
338
- /**
339
- * Convertit hex en RGBA avec alpha
340
- * @private
341
- */
342
- hexToRgba(hex, alpha) {
343
- const rgb = this.hexToRgb(hex);
344
- return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
345
- }
297
+ // ─────────────────────────────────────────
298
+ // DESTROY
299
+ // ─────────────────────────────────────────
346
300
 
347
- /**
348
- * Vérifie si un point est dans les limites
349
- */
350
- isPointInside(x, y) {
351
- return x >= this.x &&
352
- x <= this.x + this.width &&
353
- y >= this.y &&
354
- y <= this.y + this.height;
301
+ destroy() {
302
+ if (this._rafId) {
303
+ cancelAnimationFrame(this._rafId);
304
+ this._rafId = null;
305
+ }
306
+ this.ripples = [];
307
+ super.destroy();
355
308
  }
356
309
  }
357
310