canvasframework 0.3.8 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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;