canvasframework 0.3.18 → 0.3.20

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,342 @@
1
+ // components/Banner.js
2
+ import Component from '../core/Component.js';
3
+
4
+ export default class Banner extends Component {
5
+ constructor(framework, options = {}) {
6
+ super(framework, options);
7
+
8
+ this.text = options.text || '';
9
+ this.type = options.type || 'info';
10
+ this.actions = options.actions || [];
11
+ this.dismissible = options.dismissible === true;
12
+
13
+ this.platform = framework.platform || 'material';
14
+
15
+ this.width = options.width || framework.width || window.innerWidth;
16
+ this.height = options.height || 64;
17
+ this.x = options.x || 0;
18
+ this.y = options.y || 0;
19
+
20
+ this.visible = options.visible !== false;
21
+ this.progress = this.visible ? 1 : 0;
22
+ this.animSpeed = 0.18;
23
+
24
+ this._lastUpdate = performance.now();
25
+ this._colors = this._resolveColors();
26
+
27
+ // Bounds calculées à chaque frame
28
+ this._actionBounds = [];
29
+ this._dismissBounds = null;
30
+
31
+ // Pour indiquer qu'on gère nos propres clics
32
+ this.selfManagedClicks = true;
33
+
34
+ // Écouter les événements directement sur le canvas
35
+ this._setupEventListeners();
36
+
37
+ // Ref si fourni
38
+ if (options.ref) options.ref.current = this;
39
+ }
40
+
41
+ /* ===================== Setup ===================== */
42
+ _setupEventListeners() {
43
+ // Stocker les références pour pouvoir les retirer plus tard
44
+ this._boundHandleClick = this._handleClick.bind(this);
45
+
46
+ // Écouter les événements sur le canvas parent
47
+ if (this.framework && this.framework.canvas) {
48
+ this.framework.canvas.addEventListener('click', this._boundHandleClick);
49
+ this.framework.canvas.addEventListener('touchend', this._boundHandleClick);
50
+ }
51
+ }
52
+
53
+ _removeEventListeners() {
54
+ if (this.framework && this.framework.canvas && this._boundHandleClick) {
55
+ this.framework.canvas.removeEventListener('click', this._boundHandleClick);
56
+ this.framework.canvas.removeEventListener('touchend', this._boundHandleClick);
57
+ }
58
+ }
59
+
60
+ /* ===================== Lifecycle ===================== */
61
+ onMount() {
62
+ this._setupEventListeners();
63
+ }
64
+
65
+ onUnmount() {
66
+ this._removeEventListeners();
67
+ }
68
+
69
+ /* ===================== Colors ===================== */
70
+ _resolveColors() {
71
+ if (this.platform === 'cupertino') {
72
+ return {
73
+ bg: 'rgba(250,250,250,0.95)',
74
+ fg: '#000',
75
+ accent: '#007AFF',
76
+ divider: 'rgba(60,60,67,0.15)'
77
+ };
78
+ }
79
+
80
+ // Material v3
81
+ const map = {
82
+ info: '#E8F0FE',
83
+ success: '#E6F4EA',
84
+ warning: '#FEF7E0',
85
+ error: '#FCE8E6'
86
+ };
87
+
88
+ return {
89
+ bg: map[this.type] || map.info,
90
+ fg: '#1F1F1F',
91
+ accent: '#1A73E8'
92
+ };
93
+ }
94
+
95
+ /* ===================== Show/Hide ===================== */
96
+ show() {
97
+ this.visible = true;
98
+ this.markDirty();
99
+ }
100
+
101
+ hide() {
102
+ this.visible = false;
103
+ this.markDirty();
104
+ }
105
+
106
+ /* ===================== Update ===================== */
107
+ update() {
108
+ const now = performance.now();
109
+ const dt = Math.min((now - this._lastUpdate) / 16.6, 3);
110
+
111
+ const target = this.visible ? 1 : 0;
112
+ this.progress += (target - this.progress) * this.animSpeed * dt;
113
+ this.progress = Math.max(0, Math.min(1, this.progress));
114
+
115
+ if (Math.abs(target - this.progress) > 0.01) this.markDirty();
116
+
117
+ this._lastUpdate = now;
118
+ }
119
+
120
+ /* ===================== Draw ===================== */
121
+ draw(ctx) {
122
+ this.update();
123
+ if (this.progress <= 0.01) return;
124
+
125
+ const h = this.height * this.progress;
126
+ const visibleHeight = h;
127
+
128
+ ctx.save();
129
+
130
+ // Background
131
+ if (this.platform === 'material') {
132
+ ctx.shadowColor = 'rgba(0,0,0,0.18)';
133
+ ctx.shadowBlur = 8;
134
+ ctx.shadowOffsetY = 2;
135
+ }
136
+
137
+ ctx.fillStyle = this._colors.bg;
138
+ ctx.fillRect(this.x, this.y, this.width, visibleHeight);
139
+ ctx.shadowColor = 'transparent';
140
+
141
+ // Divider iOS
142
+ if (this.platform === 'cupertino') {
143
+ ctx.strokeStyle = this._colors.divider;
144
+ ctx.beginPath();
145
+ ctx.moveTo(this.x, this.y + visibleHeight);
146
+ ctx.lineTo(this.x + this.width, this.y + visibleHeight);
147
+ ctx.stroke();
148
+ }
149
+
150
+ // Text
151
+ ctx.fillStyle = this._colors.fg;
152
+ ctx.font =
153
+ this.platform === 'cupertino'
154
+ ? '400 15px -apple-system'
155
+ : '400 14px Roboto, sans-serif';
156
+ ctx.textBaseline = 'middle';
157
+ ctx.textAlign = 'left';
158
+ ctx.fillText(this.text, this.x + 16, this.y + visibleHeight / 2);
159
+
160
+ // Actions - calculer et stocker les bounds
161
+ this._actionBounds = [];
162
+ let x = this.width - 16;
163
+
164
+ for (let i = this.actions.length - 1; i >= 0; i--) {
165
+ const action = this.actions[i];
166
+ const textWidth = ctx.measureText(action.label).width + 20;
167
+ x -= textWidth;
168
+
169
+ ctx.fillStyle = this._colors.accent;
170
+ ctx.textAlign = 'center';
171
+ ctx.textBaseline = 'middle';
172
+ ctx.fillText(action.label, this.x + x + textWidth / 2, this.y + visibleHeight / 2);
173
+
174
+ // Stocker la hitbox (en coordonnées écran, pas canvas)
175
+ this._actionBounds.push({
176
+ action: action,
177
+ bounds: {
178
+ x: this.x + x,
179
+ y: this.y + (visibleHeight - 44) / 2,
180
+ w: textWidth,
181
+ h: 44
182
+ }
183
+ });
184
+
185
+ x -= 12;
186
+ }
187
+
188
+ // Dismiss button
189
+ if (this.dismissible) {
190
+ const hitSize = 44;
191
+ const cx = this.width - 28;
192
+ const cy = this.y + visibleHeight / 2;
193
+
194
+ ctx.fillStyle =
195
+ this.platform === 'cupertino'
196
+ ? 'rgba(60,60,67,0.6)'
197
+ : this._colors.fg;
198
+
199
+ ctx.font =
200
+ this.platform === 'cupertino'
201
+ ? '600 16px -apple-system'
202
+ : '500 16px Roboto';
203
+ ctx.textAlign = 'center';
204
+ ctx.textBaseline = 'middle';
205
+ ctx.fillText('×', cx, cy);
206
+
207
+ this._dismissBounds = {
208
+ x: cx - hitSize / 2,
209
+ y: cy - hitSize / 2,
210
+ w: hitSize,
211
+ h: hitSize
212
+ };
213
+ } else {
214
+ this._dismissBounds = null;
215
+ }
216
+
217
+ ctx.restore();
218
+
219
+ // DEBUG: Dessiner les hitboxes
220
+ if (this.framework && this.framework.debbug) {
221
+ this._drawDebugHitboxes(ctx);
222
+ }
223
+ }
224
+
225
+ /* ===================== Debug ===================== */
226
+ _drawDebugHitboxes(ctx) {
227
+ ctx.save();
228
+ ctx.strokeStyle = 'red';
229
+ ctx.lineWidth = 1;
230
+ ctx.fillStyle = 'rgba(255, 0, 0, 0.1)';
231
+
232
+ // Dessiner la hitbox principale du banner
233
+ const h = this.height * this.progress;
234
+ ctx.strokeRect(this.x, this.y, this.width, h);
235
+
236
+ // Dessiner les hitboxes des actions
237
+ if (this._actionBounds && this._actionBounds.length > 0) {
238
+ for (const item of this._actionBounds) {
239
+ const b = item.bounds;
240
+ ctx.fillRect(b.x, b.y, b.w, b.h);
241
+ ctx.strokeRect(b.x, b.y, b.w, b.h);
242
+
243
+ // Texte de debug
244
+ ctx.fillStyle = 'red';
245
+ ctx.font = '10px monospace';
246
+ ctx.fillText(item.action.label, b.x + 5, b.y + 12);
247
+ }
248
+ }
249
+
250
+ // Dessiner la hitbox du dismiss button
251
+ if (this._dismissBounds) {
252
+ const b = this._dismissBounds;
253
+ ctx.fillRect(b.x, b.y, b.w, b.h);
254
+ ctx.strokeRect(b.x, b.y, b.w, b.h);
255
+ ctx.fillText('X', b.x + 5, b.y + 12);
256
+ }
257
+
258
+ ctx.restore();
259
+ }
260
+
261
+ /* ===================== Click Handling ===================== */
262
+ _handleClick(event) {
263
+ if (this.progress < 0.95) return;
264
+
265
+ // Obtenir les coordonnées du clic/touch
266
+ let clientX, clientY;
267
+
268
+ if (event.type === 'touchend') {
269
+ const touch = event.changedTouches[0];
270
+ clientX = touch.clientX;
271
+ clientY = touch.clientY;
272
+ } else {
273
+ clientX = event.clientX;
274
+ clientY = event.clientY;
275
+ }
276
+
277
+ // Convertir en coordonnées canvas SIMPLIFIÉ
278
+ const canvasRect = this.framework.canvas.getBoundingClientRect();
279
+
280
+ // Coordonnées relatives au canvas (en pixels CSS, pas en pixels canvas)
281
+ const x = clientX - canvasRect.left;
282
+ const y = clientY - canvasRect.top;
283
+
284
+ console.log('Click converted:', {
285
+ clientX, clientY,
286
+ canvasLeft: canvasRect.left,
287
+ canvasTop: canvasRect.top,
288
+ x, y,
289
+ bannerX: this.x,
290
+ bannerY: this.y,
291
+ bannerWidth: this.width,
292
+ bannerHeight: this.height * this.progress
293
+ });
294
+
295
+ // Vérifier si on clique sur le banner (en coordonnées CSS)
296
+ const bannerBottom = this.y + (this.height * this.progress);
297
+ if (x < this.x || x > this.x + this.width || y < this.y || y > bannerBottom) {
298
+ console.log('Click outside banner');
299
+ return;
300
+ }
301
+
302
+ console.log('Click INSIDE banner!');
303
+
304
+ // Empêcher la propagation
305
+ event.stopPropagation();
306
+
307
+ // 1️⃣ Dismiss button
308
+ if (this.dismissible && this._dismissBounds) {
309
+ const b = this._dismissBounds;
310
+ console.log('Checking dismiss bounds:', b, 'click:', {x, y});
311
+ if (x >= b.x && x <= b.x + b.w &&
312
+ y >= b.y && y <= b.y + b.h) {
313
+ console.log('Dismiss clicked!');
314
+ this.hide();
315
+ return;
316
+ }
317
+ }
318
+
319
+ // 2️⃣ Actions
320
+ if (this._actionBounds && this._actionBounds.length > 0) {
321
+ console.log('Checking', this._actionBounds.length, 'action bounds');
322
+ for (const item of this._actionBounds) {
323
+ const b = item.bounds;
324
+ console.log('Checking action:', item.action.label, 'bounds:', b);
325
+ if (x >= b.x && x <= b.x + b.w &&
326
+ y >= b.y && y <= b.y + b.h) {
327
+ console.log('Action clicked:', item.action.label);
328
+ item.action.onClick?.();
329
+ return;
330
+ }
331
+ }
332
+ }
333
+
334
+ console.log('Click on banner but not on any button');
335
+ }
336
+
337
+ /* ===================== Resize ===================== */
338
+ _resize(width) {
339
+ this.width = width;
340
+ this.markDirty();
341
+ }
342
+ }
@@ -97,19 +97,27 @@ class RadioButton extends Component {
97
97
  ctx.arc(centerX, centerY, 5, 0, Math.PI * 2);
98
98
  ctx.fill();
99
99
  }
100
- } else {
101
- // Cupertino
102
- ctx.strokeStyle = this.checked ? '#007AFF' : '#C7C7CC';
103
- ctx.lineWidth = 2;
104
- ctx.beginPath();
105
- ctx.arc(centerX, centerY, this.circleRadius, 0, Math.PI * 2);
106
- ctx.stroke();
107
-
100
+ } else {
101
+ // Cupertino (iOS style)
108
102
  if (this.checked) {
103
+ // Cercle bleu rempli
109
104
  ctx.fillStyle = '#007AFF';
110
105
  ctx.beginPath();
111
- ctx.arc(centerX, centerY, 5, 0, Math.PI * 2);
106
+ ctx.arc(centerX, centerY, this.circleRadius, 0, Math.PI * 2);
107
+ ctx.fill();
108
+
109
+ // Point blanc au centre
110
+ ctx.fillStyle = '#FFFFFF';
111
+ ctx.beginPath();
112
+ ctx.arc(centerX, centerY, 4, 0, Math.PI * 2);
112
113
  ctx.fill();
114
+ } else {
115
+ // Cercle gris clair
116
+ ctx.strokeStyle = '#D1D1D6';
117
+ ctx.lineWidth = 1.5;
118
+ ctx.beginPath();
119
+ ctx.arc(centerX, centerY, this.circleRadius, 0, Math.PI * 2);
120
+ ctx.stroke();
113
121
  }
114
122
  }
115
123
 
@@ -8,248 +8,408 @@ import Component from '../core/Component.js';
8
8
  class Tabs extends Component {
9
9
  constructor(framework, options = {}) {
10
10
  super(framework, options);
11
+
11
12
  this.tabs = options.tabs || [];
12
13
  this.selectedIndex = options.selectedIndex || 0;
13
14
  this.onChange = options.onChange;
14
15
  this.platform = framework.platform;
15
- this.height = options.height || 48;
16
- this.indicatorColor = options.indicatorColor || (framework.platform === 'material' ? '#6200EE' : '#007AFF');
17
- this.textColor = options.textColor || '#000000';
16
+ this.height = options.height || 56;
17
+
18
+ this.indicatorColor = options.indicatorColor ||
19
+ (this.platform === 'material' ? '#6200EE' : '#007AFF');
20
+ this.textColor = options.textColor ||
21
+ (this.platform === 'material' ? '#000000' : '#8E8E93');
18
22
  this.selectedTextColor = options.selectedTextColor || this.indicatorColor;
19
23
 
20
- // 🔹 Système de ripples amélioré
24
+ // Ripple pour Material
21
25
  this.ripples = [];
22
26
  this.animationFrame = null;
23
27
  this.lastAnimationTime = 0;
24
28
 
25
- // 🔹 Pour détecter les doubles événements
26
- this.lastEventTime = 0;
27
- this.lastEventCoords = { x: -1, y: -1 };
29
+ // Animation pour Cupertino
30
+ this.pressedTabIndex = -1;
31
+ this.pressAnimation = 0;
32
+
33
+ // ✅ Structure: tableau de tableaux d'enfants
34
+ // tabChildren[0] = [enfants du tab 0]
35
+ // tabChildren[1] = [enfants du tab 1]
36
+ this.tabChildren = this.tabs.map(() => []);
28
37
 
38
+ // ✅ Configuration: nombre d'enfants par tab
39
+ // Si défini, distribue automatiquement les enfants
40
+ // Ex: childrenPerTab = [3, 2] => 3 enfants pour tab 0, 2 pour tab 1
41
+ this.childrenPerTab = options.childrenPerTab || null;
42
+ this.currentTabIndex = 0;
43
+ this.childAddCount = 0; // Compteur d'enfants ajoutés
44
+
45
+ // Gestionnaire de clic
29
46
  this.onPress = this.handlePress.bind(this);
47
+
48
+ // Position par défaut
49
+ this.position = options.position || (this.platform === 'cupertino' ? 'bottom' : 'top');
50
+
51
+ if (this.position === 'bottom' && !options.y) {
52
+ this.y = framework.height - this.height;
53
+ } else if (this.position === 'top' && !options.y) {
54
+ this.y = options.appbar || 0;
55
+ }
56
+
57
+ // Zone de contenu (sous les tabs)
58
+ this.contentY = this.y + this.height;
59
+ this.contentHeight = framework.height - this.height;
30
60
  }
31
61
 
32
62
  /**
33
- * Démarre l'animation des ripples
34
- * @private
63
+ * Définit le tab actuel pour l'ajout d'enfants (appelé par UIBuilder)
64
+ * @param {number} tabIndex - Index du tab
35
65
  */
36
- startRippleAnimation() {
37
- const animate = (timestamp) => {
38
- if (!this.lastAnimationTime) this.lastAnimationTime = timestamp;
39
- const deltaTime = timestamp - this.lastAnimationTime;
40
- this.lastAnimationTime = timestamp;
41
-
42
- let needsUpdate = false;
43
-
44
- // Mettre à jour chaque ripple
45
- for (let i = this.ripples.length - 1; i >= 0; i--) {
46
- const ripple = this.ripples[i];
47
-
48
- // Animer le rayon (expansion)
49
- if (ripple.radius < ripple.maxRadius) {
50
- ripple.radius += (ripple.maxRadius / 300) * deltaTime;
51
- needsUpdate = true;
52
- }
53
-
54
- // Animer l'opacité (fade out)
55
- if (ripple.radius >= ripple.maxRadius * 0.4) {
56
- ripple.opacity -= (0.003 * deltaTime);
57
- if (ripple.opacity < 0) ripple.opacity = 0;
58
- needsUpdate = true;
59
- }
60
-
61
- // Supprimer les ripples terminés
62
- if (ripple.opacity <= 0 && ripple.radius >= ripple.maxRadius * 0.95) {
63
- this.ripples.splice(i, 1);
64
- needsUpdate = true;
65
- }
66
- }
66
+ setCurrentTab(tabIndex) {
67
+ if (tabIndex >= 0 && tabIndex < this.tabChildren.length) {
68
+ this.currentTabIndex = tabIndex;
69
+ }
70
+ }
67
71
 
68
- // Redessiner si nécessaire
69
- if (needsUpdate) {
70
- this.requestRender();
72
+ /**
73
+ * Ajoute un enfant au tab en cours
74
+ * Distribution automatique: divise les enfants équitablement entre les tabs
75
+ * @param {Component} child - Composant enfant
76
+ * @returns {Component} L'enfant ajouté
77
+ */
78
+ add(child) {
79
+ // Coordonnées relatives à la zone de contenu
80
+ child.x = child.x || 0;
81
+ child.y = child.y || 0;
82
+
83
+ // Dimensions par défaut
84
+ if (!child.width) child.width = this.framework.width;
85
+
86
+ // Marquer l'enfant comme appartenant à ce Tabs
87
+ child.parentTabs = this;
88
+
89
+ // ✅ Calculer quel tab doit recevoir cet enfant
90
+ // On distribue équitablement les enfants entre les tabs
91
+ const totalChildren = this.tabChildren.reduce((sum, arr) => sum + arr.length, 0);
92
+ const childrenPerTab = Math.ceil(totalChildren / this.tabs.length);
93
+
94
+ // Trouver le premier tab qui n'est pas encore plein
95
+ let targetTabIndex = 0;
96
+ for (let i = 0; i < this.tabChildren.length; i++) {
97
+ if (this.tabChildren[i].length < childrenPerTab) {
98
+ targetTabIndex = i;
99
+ break;
71
100
  }
72
-
73
- // Continuer l'animation
74
- if (this.ripples.length > 0) {
75
- this.animationFrame = requestAnimationFrame(animate);
76
- } else {
77
- this.animationFrame = null;
78
- this.lastAnimationTime = 0;
101
+ // Si tous les tabs ont childrenPerTab enfants, recommencer à 0
102
+ if (i === this.tabChildren.length - 1) {
103
+ targetTabIndex = totalChildren % this.tabs.length;
79
104
  }
80
- };
81
-
82
- if (this.ripples.length > 0 && !this.animationFrame) {
83
- this.animationFrame = requestAnimationFrame(animate);
84
105
  }
106
+
107
+ // Ajouter au tableau du tab calculé
108
+ this.tabChildren[targetTabIndex].push(child);
109
+
110
+ // Visibilité selon le tab sélectionné
111
+ child.visible = (targetTabIndex === this.selectedIndex);
112
+
113
+ return child;
85
114
  }
86
115
 
87
116
  /**
88
- * Demander un redessin
89
- * @private
117
+ * Met à jour la visibilité des enfants selon l'onglet sélectionné
90
118
  */
91
- requestRender() {
92
- if (this.framework && this.framework.requestRender) {
93
- this.framework.requestRender();
94
- }
119
+ updateChildrenVisibility() {
120
+ this.tabChildren.forEach((children, tabIdx) => {
121
+ const isVisible = (tabIdx === this.selectedIndex);
122
+ children.forEach(child => {
123
+ child.visible = isVisible;
124
+ });
125
+ });
95
126
  }
96
127
 
97
128
  /**
98
- * Nettoyer l'animation lors de la destruction
129
+ * Retourne tous les enfants du tab sélectionné
99
130
  */
100
- destroy() {
101
- if (this.animationFrame) {
102
- cancelAnimationFrame(this.animationFrame);
103
- this.animationFrame = null;
104
- }
105
- if (super.destroy) {
106
- super.destroy();
107
- }
131
+ getActiveChildren() {
132
+ return this.tabChildren[this.selectedIndex] || [];
108
133
  }
109
134
 
110
135
  handlePress(x, y) {
111
- const now = performance.now();
136
+ // D'abord vérifier les clics sur les enfants
137
+ if (y > this.y + this.height && this.checkChildClick(x, y)) {
138
+ return;
139
+ }
140
+
141
+ // Ensuite vérifier les clics sur la barre de tabs
142
+ if (!this.isPointInside(x, y)) return;
112
143
 
113
- // 🔹 Détecter si c'est le même événement physique (mousedown + click)
114
- const deltaTime = now - this.lastEventTime;
115
- const deltaX = Math.abs(x - this.lastEventCoords.x);
116
- const deltaY = Math.abs(y - this.lastEventCoords.y);
144
+ const tabWidth = this.width / this.tabs.length;
145
+ const index = Math.floor((x - this.x) / tabWidth);
117
146
 
118
- const isDoubleEvent = deltaX < 2 && deltaY < 2 && deltaTime < 50;
147
+ if (index < 0 || index >= this.tabs.length) return;
119
148
 
120
- if (isDoubleEvent) {
121
- return; // Ignorer le double événement
149
+ // Ripple pour Material
150
+ if (this.platform === 'material') {
151
+ const rippleCenterX = this.x + index * tabWidth + tabWidth / 2;
152
+ const maxRippleRadius = Math.min(tabWidth * 0.6, this.height * 0.8);
153
+
154
+ this.ripples.push({
155
+ x: rippleCenterX,
156
+ y: this.y + this.height / 2,
157
+ radius: 0,
158
+ maxRadius: maxRippleRadius,
159
+ opacity: 1
160
+ });
161
+
162
+ if (!this.animationFrame) this.startRippleAnimation();
163
+ }
164
+ // Animation Cupertino
165
+ else if (this.platform === 'cupertino') {
166
+ this.pressedTabIndex = index;
167
+ this.pressAnimation = 1;
168
+ this.requestRender();
169
+ setTimeout(() => this.animatePressRelease(), 100);
122
170
  }
123
171
 
124
- // 🔹 Enregistrer cet événement
125
- this.lastEventTime = now;
126
- this.lastEventCoords = { x, y };
172
+ // Changement d'onglet
173
+ if (index !== this.selectedIndex) {
174
+ this.selectedIndex = index;
175
+ this.updateChildrenVisibility();
176
+ if (this.onChange) this.onChange(index, this.tabs[index]);
177
+ }
127
178
 
128
- const tabWidth = this.width / this.tabs.length;
129
- const index = Math.floor((x - this.x) / tabWidth);
179
+ this.requestRender();
180
+ }
181
+
182
+ /**
183
+ * ✅ Vérifie les clics sur les enfants du tab actif
184
+ */
185
+ checkChildClick(x, y) {
186
+ const adjustedY = y - (this.framework.scrollOffset || 0);
187
+ const activeChildren = this.getActiveChildren();
130
188
 
131
- if (index >= 0 && index < this.tabs.length) {
132
- // Ripple pour Material
133
- if (this.platform === 'material') {
134
- const rippleCenterX = this.x + index * tabWidth + tabWidth / 2;
135
-
136
- // Calculer la taille maximale du ripple
137
- const maxRippleRadius = Math.min(tabWidth * 0.6, this.height * 0.8);
138
-
139
- this.ripples.push({
140
- x: rippleCenterX,
141
- y: this.y + this.height / 2,
142
- radius: 0,
143
- maxRadius: maxRippleRadius,
144
- opacity: 1,
145
- createdAt: now,
146
- tabIndex: index
147
- });
189
+ // Parcourir en ordre inverse (derniers ajoutés = au dessus)
190
+ for (let i = activeChildren.length - 1; i >= 0; i--) {
191
+ const child = activeChildren[i];
192
+
193
+ if (!child.visible) continue;
194
+
195
+ // Calculer les coordonnées absolues de l'enfant
196
+ const childX = this.x + child.x;
197
+ const childY = this.contentY + child.y;
198
+
199
+ // Vérifier si le clic est dans l'enfant
200
+ if (adjustedY >= childY &&
201
+ adjustedY <= childY + child.height &&
202
+ x >= childX &&
203
+ x <= childX + child.width) {
148
204
 
149
- // Démarrer l'animation si elle n'est pas en cours
150
- if (!this.animationFrame) {
151
- this.startRippleAnimation();
205
+ // Si l'enfant a un onClick ou onPress, le déclencher
206
+ if (child.onClick) {
207
+ child.onClick();
208
+ return true;
209
+ } else if (child.onPress) {
210
+ child.onPress(x, adjustedY);
211
+ return true;
152
212
  }
153
-
154
- // Forcer un redessin
155
- this.requestRender();
156
213
  }
214
+ }
215
+
216
+ return false;
217
+ }
157
218
 
158
- // Changement d'onglet
159
- if (index !== this.selectedIndex) {
160
- this.selectedIndex = index;
161
- if (this.onChange) this.onChange(index, this.tabs[index]);
162
- }
219
+ animatePressRelease() {
220
+ let startTime = null;
221
+ const duration = 150;
222
+
223
+ const animate = (timestamp) => {
224
+ if (!startTime) startTime = timestamp;
225
+ const progress = Math.min((timestamp - startTime) / duration, 1);
163
226
 
227
+ this.pressAnimation = 1 - progress;
164
228
  this.requestRender();
165
- }
229
+
230
+ if (progress < 1) requestAnimationFrame(animate);
231
+ else {
232
+ this.pressAnimation = 0;
233
+ this.pressedTabIndex = -1;
234
+ }
235
+ };
236
+
237
+ requestAnimationFrame(animate);
166
238
  }
167
239
 
168
240
  isPointInside(x, y) {
169
- return x >= this.x && x <= this.x + this.width &&
241
+ return x >= this.x && x <= this.x + this.width &&
170
242
  y >= this.y && y <= this.y + this.height;
171
243
  }
172
244
 
245
+ startRippleAnimation() {
246
+ const animate = (timestamp) => {
247
+ if (!this.lastAnimationTime) this.lastAnimationTime = timestamp;
248
+ const deltaTime = timestamp - this.lastAnimationTime;
249
+ this.lastAnimationTime = timestamp;
250
+
251
+ let needsUpdate = false;
252
+
253
+ for (let i = this.ripples.length - 1; i >= 0; i--) {
254
+ const ripple = this.ripples[i];
255
+
256
+ if (ripple.radius < ripple.maxRadius)
257
+ ripple.radius += (ripple.maxRadius / 300) * deltaTime;
258
+
259
+ if (ripple.radius >= ripple.maxRadius * 0.4) {
260
+ ripple.opacity -= (0.003 * deltaTime);
261
+ if (ripple.opacity < 0) ripple.opacity = 0;
262
+ }
263
+
264
+ if (ripple.opacity <= 0 && ripple.radius >= ripple.maxRadius * 0.95)
265
+ this.ripples.splice(i, 1);
266
+
267
+ needsUpdate = true;
268
+ }
269
+
270
+ if (needsUpdate) this.requestRender();
271
+
272
+ if (this.ripples.length > 0)
273
+ this.animationFrame = requestAnimationFrame(animate);
274
+ else
275
+ this.animationFrame = null;
276
+ };
277
+
278
+ if (this.ripples.length && !this.animationFrame)
279
+ this.animationFrame = requestAnimationFrame(animate);
280
+ }
281
+
282
+ requestRender() {
283
+ if (this.framework && this.framework.requestRender)
284
+ this.framework.requestRender();
285
+ }
286
+
287
+ /**
288
+ * ✅ Dessine les tabs et les enfants du tab actif
289
+ */
173
290
  draw(ctx) {
174
291
  ctx.save();
175
292
 
293
+ // ===== DESSINER LA BARRE DE TABS =====
294
+
176
295
  // Background
177
- ctx.fillStyle = '#FFFFFF';
296
+ ctx.fillStyle = this.platform === 'material' ? '#FFF' : '#F2F2F7';
178
297
  ctx.fillRect(this.x, this.y, this.width, this.height);
179
298
 
180
- // Bordure inférieure
181
- ctx.strokeStyle = '#E0E0E0';
182
- ctx.lineWidth = 1;
183
- ctx.beginPath();
184
- ctx.moveTo(this.x, this.y + this.height);
185
- ctx.lineTo(this.x + this.width, this.y + this.height);
186
- ctx.stroke();
187
-
188
- const tabWidth = this.width / this.tabs.length;
189
-
190
- // 🔹 Dessiner les ripples Material EN PREMIER
191
299
  if (this.platform === 'material') {
192
- this.drawRipples(ctx, tabWidth);
300
+ ctx.strokeStyle = '#E0E0E0';
301
+ ctx.lineWidth = 1;
302
+ ctx.beginPath();
303
+ ctx.moveTo(this.x, this.y + this.height);
304
+ ctx.lineTo(this.x + this.width, this.y + this.height);
305
+ ctx.stroke();
193
306
  }
194
-
195
- // Dessiner les onglets
307
+
308
+ const tabWidth = this.width / this.tabs.length;
309
+
310
+ // Ripples
311
+ if (this.platform === 'material') this.drawRipples(ctx, tabWidth);
312
+
196
313
  for (let i = 0; i < this.tabs.length; i++) {
197
314
  const tab = this.tabs[i];
198
315
  const tabX = this.x + i * tabWidth;
199
316
  const isSelected = i === this.selectedIndex;
317
+
318
+ // Cupertino pressed effect
319
+ if (this.platform === 'cupertino' && i === this.pressedTabIndex) {
320
+ ctx.fillStyle = `rgba(0,122,255,${0.1 * this.pressAnimation})`;
321
+ ctx.fillRect(tabX, this.y, tabWidth, this.height);
322
+ }
323
+
324
+ // Indicators
325
+ if (this.platform === 'cupertino' && isSelected) {
326
+ ctx.fillStyle = '#007AFF';
327
+ ctx.fillRect(tabX + tabWidth/2 - 15, this.y + this.height - 2, 30, 2);
328
+ }
329
+
200
330
  const color = isSelected ? this.selectedTextColor : this.textColor;
201
-
202
- // Icône (facultative)
331
+
332
+ // Icon
203
333
  if (tab.icon) {
204
- ctx.font = '20px sans-serif';
334
+ ctx.font = '24px -apple-system, sans-serif';
205
335
  ctx.textAlign = 'center';
206
336
  ctx.textBaseline = 'middle';
207
337
  ctx.fillStyle = color;
208
- ctx.fillText(tab.icon, tabX + tabWidth / 2, this.y + 16);
338
+ const iconY = this.platform === 'material' ? this.y + 18 : this.y + 20;
339
+ ctx.fillText(tab.icon, tabX + tabWidth/2, iconY);
209
340
  }
210
-
341
+
211
342
  // Label
212
- ctx.font = `${isSelected ? 'bold ' : ''}14px -apple-system, Roboto, sans-serif`;
343
+ const fontSize = this.platform === 'material' ? 14 : 12;
344
+ const fontWeight = isSelected ? '600' : '400';
345
+ ctx.font = `${fontWeight} ${fontSize}px -apple-system, Roboto, sans-serif`;
213
346
  ctx.fillStyle = color;
214
347
  ctx.textAlign = 'center';
215
348
  ctx.textBaseline = 'middle';
216
- const labelY = tab.icon ? this.y + 36 : this.y + this.height / 2;
217
- ctx.fillText(tab.label, tabX + tabWidth / 2, labelY);
218
-
219
- // Indicateur (Material)
349
+
350
+ const labelY = this.platform === 'material'
351
+ ? (tab.icon ? this.y + 36 : this.y + this.height / 2)
352
+ : (tab.icon ? this.y + 42 : this.y + this.height / 2);
353
+
354
+ ctx.fillText(tab.label, tabX + tabWidth/2, labelY);
355
+
356
+ // Material indicator
220
357
  if (isSelected && this.platform === 'material') {
221
358
  ctx.fillStyle = this.indicatorColor;
222
- ctx.fillRect(tabX, this.y + this.height - 2, tabWidth, 2);
359
+ ctx.fillRect(tabX, this.y + this.height - 3, tabWidth, 3);
223
360
  }
224
361
  }
225
-
362
+
226
363
  ctx.restore();
364
+
365
+ // ===== DESSINER LES ENFANTS DU TAB ACTIF =====
366
+ const activeChildren = this.getActiveChildren();
367
+
368
+ for (let child of activeChildren) {
369
+ if (child.visible) {
370
+ ctx.save();
371
+
372
+ // Sauvegarder les coordonnées originales
373
+ const originalX = child.x;
374
+ const originalY = child.y;
375
+
376
+ // Ajuster les coordonnées pour être absolues
377
+ child.x = this.x + originalX;
378
+ child.y = this.contentY + originalY;
379
+
380
+ // Dessiner l'enfant
381
+ child.draw(ctx);
382
+
383
+ // Restaurer les coordonnées originales
384
+ child.x = originalX;
385
+ child.y = originalY;
386
+
387
+ ctx.restore();
388
+ }
389
+ }
227
390
  }
228
391
 
229
- /**
230
- * Dessine les ripples (Material)
231
- * @private
232
- */
233
392
  drawRipples(ctx, tabWidth) {
234
- // Sauvegarder le contexte
235
393
  ctx.save();
236
-
237
- // Créer un masque de clipping pour limiter les ripples aux onglets
238
394
  ctx.beginPath();
239
395
  ctx.rect(this.x, this.y, this.width, this.height);
240
396
  ctx.clip();
241
397
 
242
398
  for (let ripple of this.ripples) {
243
399
  ctx.globalAlpha = ripple.opacity;
244
- ctx.fillStyle = this.indicatorColor || '#6200EE';
400
+ ctx.fillStyle = this.indicatorColor;
245
401
  ctx.beginPath();
246
- ctx.arc(ripple.x, ripple.y, ripple.radius, 0, Math.PI * 2);
402
+ ctx.arc(ripple.x, ripple.y, ripple.radius, 0, Math.PI*2);
247
403
  ctx.fill();
248
404
  }
249
405
 
250
- // Restaurer le contexte
251
406
  ctx.restore();
252
407
  }
408
+
409
+ destroy() {
410
+ if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
411
+ if (super.destroy) super.destroy();
412
+ }
253
413
  }
254
414
 
255
415
  export default Tabs;
@@ -50,6 +50,7 @@ import ImageCarousel from '../components/ImageCarousel.js';
50
50
  import PasswordInput from '../components/PasswordInput.js';
51
51
  import InputTags from '../components/InputTags.js';
52
52
  import InputDatalist from '../components/InputDatalist.js';
53
+ import Banner from '../components/Banner.js';
53
54
 
54
55
  // Utils
55
56
  import SafeArea from '../utils/SafeArea.js';
@@ -122,6 +123,7 @@ const FIXED_COMPONENT_TYPES = new Set([
122
123
  Modal,
123
124
  FAB,
124
125
  Toast,
126
+ Banner,
125
127
  BottomSheet,
126
128
  ContextMenu,
127
129
  OpenStreetMap,
@@ -187,16 +189,17 @@ class CanvasFramework {
187
189
  this._lastFpsTime = performance.now();
188
190
  this.showFps = options.showFps || false; // false par défaut
189
191
  this.debbug = options.debug || false; // false par défaut (et correction de la faute de frappe)
190
- // Worker pour multithreading
191
- this.worker = new Worker(new URL('./CanvasWorker.js', import.meta.url), { type: 'module' });
192
- this.worker.onmessage = this.handleWorkerMessage.bind(this);
193
- this.worker.postMessage({ type: 'INIT', payload: { components: [] } });
194
-
195
- // Worker logique pour calculs séparés
192
+
193
+ // Worker pour multithreading Canvas Worker
194
+ this.worker = this.createCanvasWorker();
195
+ this.worker.onmessage = this.handleWorkerMessage.bind(this);
196
+ this.worker.postMessage({ type: 'INIT', payload: { components: [] } });
196
197
 
197
- this.logicWorker = new Worker(new URL('./LogicWorker.js', import.meta.url), { type: 'module' });
198
- this.logicWorker.onmessage = this.handleLogicWorkerMessage.bind(this);
199
- this.logicWorkerState = {};
198
+ // Logic Worker
199
+ this.logicWorker = this.createLogicWorker();
200
+ this.logicWorker.onmessage = this.handleLogicWorkerMessage.bind(this);
201
+ this.logicWorkerState = {};
202
+ this.logicWorker.postMessage({ type: 'SET_STATE', payload: this.state });
200
203
 
201
204
  // Envoyer l'état initial au worker
202
205
  this.logicWorker.postMessage({ type: 'SET_STATE', payload: this.state });
@@ -254,6 +257,74 @@ class CanvasFramework {
254
257
  get: () => originalFillStyle.get.call(ctx)
255
258
  });
256
259
  }
260
+
261
+ createCanvasWorker() {
262
+ const workerCode = `
263
+ let components = [];
264
+
265
+ self.onmessage = function(e) {
266
+ const { type, payload } = e.data;
267
+
268
+ switch(type) {
269
+ case 'INIT':
270
+ components = payload.components;
271
+ self.postMessage({ type: 'READY' });
272
+ break;
273
+
274
+ case 'UPDATE_LAYOUT':
275
+ const updated = components.map(comp => {
276
+ if (comp.dynamicHeight && comp.calculateHeight) {
277
+ comp.height = comp.calculateHeight();
278
+ }
279
+ return { id: comp.id, height: comp.height };
280
+ });
281
+ self.postMessage({ type: 'LAYOUT_DONE', payload: updated });
282
+ break;
283
+
284
+ case 'SCROLL_INERTIA':
285
+ let { offset, velocity, friction, maxScroll } = payload;
286
+ offset += velocity;
287
+ offset = Math.max(Math.min(offset, 0), -maxScroll);
288
+ velocity *= friction;
289
+ self.postMessage({ type: 'SCROLL_UPDATED', payload: { offset, velocity } });
290
+ break;
291
+ }
292
+ };
293
+ `;
294
+
295
+ const blob = new Blob([workerCode], { type: 'application/javascript' });
296
+ return new Worker(URL.createObjectURL(blob));
297
+ }
298
+
299
+ createLogicWorker() {
300
+ const workerCode = `
301
+ let state = {};
302
+
303
+ self.onmessage = async function(e) {
304
+ const { type, payload } = e.data;
305
+
306
+ switch(type) {
307
+ case 'SET_STATE':
308
+ state = payload;
309
+ self.postMessage({ type: 'STATE_UPDATED', payload: state });
310
+ break;
311
+
312
+ case 'EXECUTE':
313
+ try {
314
+ const fn = new Function('state', 'args', payload.fnString);
315
+ const result = await fn(state, payload.args);
316
+ self.postMessage({ type: 'EXECUTION_RESULT', payload: result });
317
+ } catch (err) {
318
+ self.postMessage({ type: 'EXECUTION_ERROR', payload: err.message });
319
+ }
320
+ break;
321
+ }
322
+ };
323
+ `;
324
+
325
+ const blob = new Blob([workerCode], { type: 'application/javascript' });
326
+ return new Worker(URL.createObjectURL(blob));
327
+ }
257
328
 
258
329
  // Set Theme dynamique
259
330
  setTheme(theme) {
package/core/UIBuilder.js CHANGED
@@ -51,6 +51,7 @@ import ImageCarousel from '../components/ImageCarousel.js';
51
51
  import PasswordInput from '../components/PasswordInput.js';
52
52
  import InputTags from '../components/InputTags.js';
53
53
  import InputDatalist from '../components/InputDatalist.js';
54
+ import Banner from '../components/Banner.js';
54
55
 
55
56
  // Features
56
57
  import PullToRefresh from '../features/PullToRefresh.js';
@@ -129,6 +130,7 @@ const Components = {
129
130
  Row,
130
131
  Column,
131
132
  Positioned,
133
+ Banner,
132
134
  Stack
133
135
  };
134
136
 
package/index.js CHANGED
@@ -58,6 +58,7 @@ export { default as ImageCarousel } from './components/ImageCarousel.js';
58
58
  export { default as PasswordInput } from './components/PasswordInput.js';
59
59
  export { default as InputTags } from './components/InputTags.js';
60
60
  export { default as InputDatalist } from './components/InputDatalist.js';
61
+ export { default as Banner } from './components/Banner.js';
61
62
 
62
63
  // Utils
63
64
  export { default as SafeArea } from './utils/SafeArea.js';
@@ -99,7 +100,7 @@ export { default as FeatureFlags } from './manager/FeatureFlags.js';
99
100
 
100
101
  // Version du framework
101
102
 
102
- export const VERSION = '0.3.18';
103
+ export const VERSION = '0.3.20';
103
104
 
104
105
 
105
106
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvasframework",
3
- "version": "0.3.18",
3
+ "version": "0.3.20",
4
4
  "description": "Canvas-based cross-platform UI framework (Material & Cupertino)",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -1,32 +0,0 @@
1
- // CanvasWorker.js
2
- let components = [];
3
-
4
- self.onmessage = (e) => {
5
- const { type, payload } = e.data;
6
-
7
- switch(type) {
8
- case 'INIT':
9
- components = payload.components;
10
- self.postMessage({ type: 'READY' });
11
- break;
12
-
13
- case 'UPDATE_LAYOUT':
14
- // Recalculer la hauteur des composants dynamiques
15
- const updated = components.map(comp => {
16
- if (comp.dynamicHeight && comp.calculateHeight) {
17
- comp.height = comp.calculateHeight();
18
- }
19
- return { id: comp.id, height: comp.height };
20
- });
21
- self.postMessage({ type: 'LAYOUT_DONE', payload: updated });
22
- break;
23
-
24
- case 'SCROLL_INERTIA':
25
- let { offset, velocity, friction, maxScroll } = payload;
26
- offset += velocity;
27
- offset = Math.max(Math.min(offset, 0), -maxScroll);
28
- velocity *= friction;
29
- self.postMessage({ type: 'SCROLL_UPDATED', payload: { offset, velocity } });
30
- break;
31
- }
32
- };
@@ -1,25 +0,0 @@
1
- // LogicWorker.js
2
- let state = {};
3
-
4
- self.onmessage = async (e) => {
5
- const { type, payload } = e.data;
6
-
7
- switch(type) {
8
- case 'SET_STATE':
9
- state = payload;
10
- self.postMessage({ type: 'STATE_UPDATED', payload: state });
11
- break;
12
-
13
- case 'EXECUTE':
14
- // payload: { fnString: string, args: array }
15
- // Attention : on envoie la fonction en string et on l'exécute ici
16
- try {
17
- const fn = new Function('state', 'args', payload.fnString);
18
- const result = await fn(state, payload.args);
19
- self.postMessage({ type: 'EXECUTION_RESULT', payload: result });
20
- } catch (err) {
21
- self.postMessage({ type: 'EXECUTION_ERROR', payload: err.message });
22
- }
23
- break;
24
- }
25
- };