canvasframework 0.7.0 → 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,236 +1,212 @@
1
1
  import Component from '../core/Component.js';
2
+ import { roundRect } from '../core/CanvasUtils.js';
3
+
2
4
  /**
3
- * Toast (notification temporaire)
4
- * @class
5
- * @extends Component
6
- * @property {string} text - Message
7
- * @property {number} duration - Durée d'affichage
8
- * @property {number} fontSize - Taille de police
9
- * @property {number} padding - Padding interne
10
- * @property {number} opacity - Opacité
11
- * @property {string} platform - Plateforme
12
- * @property {boolean} isVisible - Visibilité
13
- * @property {number} targetY - Position Y cible
14
- * @property {number} minWidth - Largeur minimale
15
- * @property {number} maxWidth - Largeur maximale
16
- * @property {boolean} animating - En cours d'animation
5
+ * Toast notification temporaire non bloquante.
6
+ *
7
+ * Corrections :
8
+ * - roundRect() vient de CanvasUtils (plus de copie locale)
9
+ * - Guard _destroyed dans les boucles RAF
10
+ * - targetY tient compte de SafeArea / bottomOffset
11
+ * - destroy() annule les RAF en cours
17
12
  */
18
13
  class Toast extends Component {
19
14
  /**
20
- * Crée une instance de Toast
21
- * @param {CanvasFramework} framework - Framework parent
22
- * @param {Object} [options={}] - Options de configuration
23
- * @param {string} [options.text=''] - Message
24
- * @param {number} [options.duration=3000] - Durée en ms
25
- * @param {number} [options.x] - Position X (auto-centré)
26
- * @param {number} [options.y] - Position Y (en bas)
15
+ * @param {CanvasFramework} framework
16
+ * @param {Object} [options={}]
17
+ * @param {string} [options.text='']
18
+ * @param {number} [options.duration=3000] - Durée d'affichage en ms
19
+ * @param {number} [options.bottomOffset=100] - Distance depuis le bas de l'écran
27
20
  */
28
21
  constructor(framework, options = {}) {
29
22
  super(framework, {
30
23
  x: 0,
31
- y: framework.height, // Commence hors écran en bas
24
+ y: framework.height,
32
25
  width: framework.width,
33
- height: 60, // Hauteur fixe pour le toast
34
- ...options
26
+ height: 60,
27
+ ...options,
35
28
  });
36
-
37
- this.text = options.text || '';
38
- this.duration = options.duration || 3000;
39
- this.fontSize = 16;
40
- this.padding = 20;
41
- this.opacity = 0;
42
- this.platform = framework.platform;
43
- this.isVisible = false;
44
-
45
- // Position cible (en bas, légèrement remonté)
46
- this.targetY = framework.height - 100;
47
-
48
- // Calculer la largeur minimale
29
+
30
+ this.text = options.text || '';
31
+ this.duration = options.duration ?? 3000;
32
+ this.fontSize = 16;
33
+ this.padding = 20;
34
+ this.opacity = 0;
35
+ this.platform = framework.platform;
36
+ this.isVisible = false;
37
+ this._rafId = null;
38
+ this._hideTimer = null;
39
+
40
+ // Offset configurable — respecte SafeArea si disponible
41
+ const safeAreaBottom = framework.safeArea?.bottom ?? 0;
42
+ const bottomOffset = options.bottomOffset ?? 100;
43
+ this.targetY = framework.height - bottomOffset - safeAreaBottom;
44
+
49
45
  this.minWidth = 200;
50
46
  this.maxWidth = Math.min(600, framework.width - 40);
51
-
52
- // Animation
53
- this.animating = false;
54
-
55
- // NE PAS appeler show() ici - laissé à l'appelant
56
47
  }
57
48
 
58
- /**
59
- * Affiche le toast
60
- */
49
+ // ─────────────────────────────────────────
50
+ // API PUBLIQUE
51
+ // ─────────────────────────────────────────
52
+
53
+ /** Affiche le toast et programme sa disparition. */
61
54
  show() {
62
55
  this.isVisible = true;
63
- this.visible = true;
64
- this.animateIn();
65
-
66
- // Auto-dismiss après la durée
67
- setTimeout(() => {
68
- if (this.isVisible) {
69
- this.hide();
70
- }
56
+ this.visible = true;
57
+ this._animateIn();
58
+
59
+ this._hideTimer = setTimeout(() => {
60
+ if (this.isVisible) this.hide();
71
61
  }, this.duration);
72
62
  }
73
63
 
74
- /**
75
- * Cache le toast
76
- */
64
+ /** Masque le toast. */
77
65
  hide() {
78
- this.animateOut();
66
+ if (this._hideTimer) {
67
+ clearTimeout(this._hideTimer);
68
+ this._hideTimer = null;
69
+ }
70
+ this._animateOut();
79
71
  }
80
72
 
81
- /**
82
- * Anime l'entrée
83
- * @private
84
- */
85
- animateIn() {
86
- if (this.animating) return;
87
- this.animating = true;
88
-
73
+ // ─────────────────────────────────────────
74
+ // ANIMATIONS
75
+ // ─────────────────────────────────────────
76
+
77
+ /** @private */
78
+ _animateIn() {
79
+ if (this._rafId) return;
80
+
89
81
  const animate = () => {
82
+ if (this._destroyed) { this._rafId = null; return; }
83
+
90
84
  this.opacity += 0.1;
91
- this.y -= (this.y - this.targetY) * 0.2;
92
-
85
+ this.y -= (this.y - this.targetY) * 0.2;
86
+
93
87
  if (this.opacity >= 1 && Math.abs(this.y - this.targetY) < 1) {
94
88
  this.opacity = 1;
95
- this.y = this.targetY;
96
- this.animating = false;
89
+ this.y = this.targetY;
90
+ this._rafId = null;
97
91
  return;
98
92
  }
99
-
100
- requestAnimationFrame(animate);
93
+
94
+ this._rafId = requestAnimationFrame(animate);
101
95
  };
102
-
103
- animate();
96
+
97
+ this._rafId = requestAnimationFrame(animate);
104
98
  }
105
99
 
106
- /**
107
- * Anime la sortie
108
- * @private
109
- */
110
- animateOut() {
111
- if (this.animating) return;
112
- this.animating = true;
113
-
100
+ /** @private */
101
+ _animateOut() {
102
+ if (this._rafId) {
103
+ cancelAnimationFrame(this._rafId);
104
+ this._rafId = null;
105
+ }
106
+
114
107
  const animate = () => {
108
+ if (this._destroyed) { this._rafId = null; return; }
109
+
115
110
  this.opacity -= 0.1;
116
- this.y += 5;
117
-
111
+ this.y += 5;
112
+
118
113
  if (this.opacity <= 0) {
119
- this.opacity = 0;
114
+ this.opacity = 0;
120
115
  this.isVisible = false;
121
- this.visible = false;
122
- this.animating = false;
116
+ this.visible = false;
117
+ this._rafId = null;
123
118
  this.framework.remove(this);
124
119
  return;
125
120
  }
126
-
127
- requestAnimationFrame(animate);
121
+
122
+ this._rafId = requestAnimationFrame(animate);
128
123
  };
129
-
130
- animate();
124
+
125
+ this._rafId = requestAnimationFrame(animate);
131
126
  }
132
127
 
133
- /**
134
- * Dessine le toast
135
- * @param {CanvasRenderingContext2D} ctx - Contexte de dessin
136
- */
128
+ // ─────────────────────────────────────────
129
+ // DESSIN
130
+ // ─────────────────────────────────────────
131
+
137
132
  draw(ctx) {
138
133
  if (!this.isVisible || this.opacity <= 0) return;
139
-
134
+
140
135
  ctx.save();
141
136
  ctx.globalAlpha = this.opacity;
142
-
143
- // Calculer la largeur en fonction du texte
137
+
144
138
  ctx.font = `${this.fontSize}px -apple-system, BlinkMacSystemFont, 'Roboto', sans-serif`;
145
- const textWidth = ctx.measureText(this.text).width;
139
+ const textWidth = ctx.measureText(this.text).width;
146
140
  const toastWidth = Math.min(
147
141
  this.maxWidth,
148
142
  Math.max(this.minWidth, textWidth + this.padding * 2)
149
143
  );
150
-
151
- // Position centrée horizontalement
144
+
152
145
  const toastX = (this.framework.width - toastWidth) / 2;
153
146
  const toastY = this.y;
154
-
147
+
155
148
  if (this.platform === 'material') {
156
- // Material Toast
157
- ctx.fillStyle = '#323232';
158
- ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
159
- ctx.shadowBlur = 15;
160
- ctx.shadowOffsetY = 4;
149
+ ctx.fillStyle = '#323232';
150
+ ctx.shadowColor = 'rgba(0,0,0,0.3)';
151
+ ctx.shadowBlur = 15;
152
+ ctx.shadowOffsetY = 4;
161
153
  ctx.beginPath();
162
- this.roundRect(ctx, toastX, toastY, toastWidth, this.height, 8);
154
+ roundRect(ctx, toastX, toastY, toastWidth, this.height, 8);
163
155
  ctx.fill();
164
156
  } else {
165
- // Cupertino Toast
166
- ctx.fillStyle = 'rgba(0, 0, 0, 0.85)';
167
- ctx.shadowColor = 'rgba(0, 0, 0, 0.2)';
168
- ctx.shadowBlur = 20;
169
- ctx.shadowOffsetY = 4;
157
+ ctx.fillStyle = 'rgba(0,0,0,0.85)';
158
+ ctx.shadowColor = 'rgba(0,0,0,0.2)';
159
+ ctx.shadowBlur = 20;
160
+ ctx.shadowOffsetY = 4;
170
161
  ctx.beginPath();
171
- this.roundRect(ctx, toastX, toastY, toastWidth, this.height, 14);
162
+ roundRect(ctx, toastX, toastY, toastWidth, this.height, 14);
172
163
  ctx.fill();
173
164
  }
174
-
175
- ctx.shadowColor = 'transparent';
176
- ctx.shadowBlur = 0;
177
-
178
- // Texte
179
- ctx.fillStyle = '#FFFFFF';
180
- ctx.font = `${this.fontSize}px -apple-system, BlinkMacSystemFont, 'Roboto', sans-serif`;
181
- ctx.textAlign = 'center';
165
+
166
+ ctx.shadowColor = 'transparent';
167
+ ctx.shadowBlur = 0;
168
+ ctx.shadowOffsetY = 0;
169
+
170
+ // Texte (tronqué si nécessaire)
171
+ ctx.fillStyle = '#FFFFFF';
172
+ ctx.textAlign = 'center';
182
173
  ctx.textBaseline = 'middle';
183
-
184
- // Tronquer le texte si nécessaire
174
+
185
175
  let displayText = this.text;
186
176
  if (textWidth > toastWidth - this.padding * 2) {
187
- // Trouver où couper le texte
188
- let truncated = this.text;
189
177
  for (let i = this.text.length; i > 0; i--) {
190
- truncated = this.text.substring(0, i) + '...';
178
+ const truncated = this.text.substring(0, i) + '...';
191
179
  if (ctx.measureText(truncated).width <= toastWidth - this.padding * 2) {
192
180
  displayText = truncated;
193
181
  break;
194
182
  }
195
183
  }
196
184
  }
197
-
185
+
198
186
  ctx.fillText(displayText, toastX + toastWidth / 2, toastY + this.height / 2);
199
-
200
- ctx.restore();
201
- }
202
187
 
203
- /**
204
- * Dessine un rectangle avec coins arrondis
205
- * @param {CanvasRenderingContext2D} ctx - Contexte de dessin
206
- * @param {number} x - Position X
207
- * @param {number} y - Position Y
208
- * @param {number} width - Largeur
209
- * @param {number} height - Hauteur
210
- * @param {number} radius - Rayon des coins
211
- * @private
212
- */
213
- roundRect(ctx, x, y, width, height, radius) {
214
- ctx.beginPath();
215
- ctx.moveTo(x + radius, y);
216
- ctx.lineTo(x + width - radius, y);
217
- ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
218
- ctx.lineTo(x + width, y + height - radius);
219
- ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
220
- ctx.lineTo(x + radius, y + height);
221
- ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
222
- ctx.lineTo(x, y + radius);
223
- ctx.quadraticCurveTo(x, y, x + radius, y);
224
- ctx.closePath();
188
+ ctx.restore();
225
189
  }
226
190
 
227
- /**
228
- * Vérifie si un point est dans les limites
229
- * @returns {boolean} False (non cliquable)
230
- */
231
191
  isPointInside() {
232
192
  return false; // Non cliquable
233
193
  }
194
+
195
+ // ─────────────────────────────────────────
196
+ // DESTROY
197
+ // ─────────────────────────────────────────
198
+
199
+ destroy() {
200
+ if (this._rafId) {
201
+ cancelAnimationFrame(this._rafId);
202
+ this._rafId = null;
203
+ }
204
+ if (this._hideTimer) {
205
+ clearTimeout(this._hideTimer);
206
+ this._hideTimer = null;
207
+ }
208
+ super.destroy();
209
+ }
234
210
  }
235
211
 
236
212
  export default Toast;
@@ -2,119 +2,148 @@ import Component from '../core/Component.js';
2
2
  import ListItem from '../components/ListItem.js';
3
3
 
4
4
  /**
5
- * Virtual List : optimise le rendu pour les longues listes
6
- * @class
7
- * @extends Component
5
+ * VirtualList rendu optimisé pour les longues listes.
6
+ *
7
+ * Corrections :
8
+ * - visibleItems passe de Array à Map<index, ListItem> → lookup O(1)
9
+ * - scroll() appelle markDirty() pour déclencher le re-render
10
+ * - destroy() supprime tous les items restants
8
11
  */
9
12
  class VirtualList extends Component {
13
+ /**
14
+ * @param {CanvasFramework} framework
15
+ * @param {Object} [options={}]
16
+ * @param {number} [options.itemHeight=56]
17
+ * @param {number} [options.height] - Hauteur du viewport visible
18
+ * @param {Function} [options.onItemClick] - (index, data) => void
19
+ */
10
20
  constructor(framework, options = {}) {
11
21
  super(framework, options);
12
22
 
13
- this.allItemsData = []; // Stocke toutes les données des items (mais pas tous les objets)
14
- this.visibleItems = []; // Liste des ListItem réellement créés/dessinés
15
- this.itemHeight = options.itemHeight || 56;
16
- this.onItemClick = options.onItemClick;
17
- this.y = options.y || 0;
18
-
19
- this.viewportHeight = options.height || framework.height; // Hauteur visible
20
- this.scrollOffset = 0; // Position de scroll
23
+ this.allItemsData = []; // Toutes les données (légères)
24
+ this.visibleItems = new Map(); // Map<index, ListItem> O(1) lookup
25
+ this.itemHeight = options.itemHeight || 56;
26
+ this.onItemClick = options.onItemClick;
27
+ this.y = options.y || 0;
28
+ this.viewportHeight = options.height || framework.height;
29
+ this.scrollOffset = 0;
21
30
  }
22
31
 
32
+ // ─────────────────────────────────────────
33
+ // API PUBLIQUE
34
+ // ─────────────────────────────────────────
35
+
23
36
  /**
24
- * Ajoute un item (seule la data est stockée)
25
- * @param {Object} itemOptions
37
+ * Ajoute un item (seule la donnée est stockée, pas l'objet visuel).
38
+ * @param {Object} itemOptions - Options passées à ListItem lors de la création
26
39
  */
27
40
  addItem(itemOptions) {
28
41
  this.allItemsData.push(itemOptions);
29
- this.updateVisibleItems();
42
+ this._updateVisibleItems();
30
43
  }
31
44
 
32
45
  /**
33
- * Supprime tous les items
46
+ * Supprime tous les items (données + objets visuels).
34
47
  */
35
48
  clear() {
36
- for (let item of this.visibleItems) {
49
+ for (const item of this.visibleItems.values()) {
37
50
  this.framework.remove(item);
38
51
  }
39
52
  this.allItemsData = [];
40
- this.visibleItems = [];
53
+ this.visibleItems.clear();
41
54
  this.height = 0;
42
55
  }
43
56
 
44
57
  /**
45
- * Met à jour la liste des items visibles selon scrollOffset
58
+ * Fait défiler la liste d'un delta.
59
+ * @param {number} deltaY - Valeur positive = vers le bas
46
60
  */
47
- updateVisibleItems() {
61
+ scroll(deltaY) {
62
+ this.scrollOffset += deltaY;
63
+ this.scrollOffset = Math.max(0, this.scrollOffset);
64
+
65
+ const maxScroll = Math.max(0, this.allItemsData.length * this.itemHeight - this.viewportHeight);
66
+ this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
67
+
68
+ this._updateVisibleItems();
69
+ this.markDirty(); // Déclenche le re-render du framework
70
+ }
71
+
72
+ // ─────────────────────────────────────────
73
+ // INTERNE
74
+ // ─────────────────────────────────────────
75
+
76
+ /** @private */
77
+ _updateVisibleItems() {
48
78
  const firstIndex = Math.floor(this.scrollOffset / this.itemHeight);
49
- const lastIndex = Math.min(this.allItemsData.length - 1, Math.ceil((this.scrollOffset + this.viewportHeight) / this.itemHeight));
79
+ const lastIndex = Math.min(
80
+ this.allItemsData.length - 1,
81
+ Math.ceil((this.scrollOffset + this.viewportHeight) / this.itemHeight)
82
+ );
50
83
 
51
- const newVisibleItems = [];
84
+ const nextVisible = new Set();
52
85
 
53
86
  for (let i = firstIndex; i <= lastIndex; i++) {
54
- let item = this.visibleItems.find(v => v.__virtualIndex === i);
55
- if (!item) {
56
- // Crée un nouvel item si pas existant
87
+ nextVisible.add(i);
88
+
89
+ const targetY = this.y + i * this.itemHeight - this.scrollOffset;
90
+
91
+ if (this.visibleItems.has(i)) {
92
+ // Mettre à jour la position Y si l'item existe déjà
93
+ this.visibleItems.get(i).y = targetY;
94
+ } else {
95
+ // Créer un nouvel item
57
96
  const data = this.allItemsData[i];
58
- item = new ListItem(this.framework, {
97
+ const item = new ListItem(this.framework, {
59
98
  ...data,
60
99
  x: this.x,
61
- y: this.y + i * this.itemHeight - this.scrollOffset,
100
+ y: targetY,
62
101
  width: this.width,
63
102
  height: this.itemHeight,
64
103
  onClick: () => {
65
- if (this.onItemClick) this.onItemClick(i, data);
66
- if (data.onClick) data.onClick();
67
- }
104
+ this.onItemClick?.(i, data);
105
+ data.onClick?.();
106
+ },
68
107
  });
69
108
  item.__virtualIndex = i;
70
109
  this.framework.add(item);
71
- } else {
72
- // Met à jour la position Y si déjà existant
73
- item.y = this.y + i * this.itemHeight - this.scrollOffset;
110
+ this.visibleItems.set(i, item);
74
111
  }
75
- newVisibleItems.push(item);
76
112
  }
77
113
 
78
- // Supprime les items qui ne sont plus visibles
79
- for (let item of this.visibleItems) {
80
- if (!newVisibleItems.includes(item)) {
114
+ // Supprimer les items qui sortent du viewport
115
+ for (const [index, item] of this.visibleItems) {
116
+ if (!nextVisible.has(index)) {
81
117
  this.framework.remove(item);
118
+ this.visibleItems.delete(index);
82
119
  }
83
120
  }
84
121
 
85
- this.visibleItems = newVisibleItems;
86
122
  this.height = this.allItemsData.length * this.itemHeight;
87
123
  }
88
124
 
89
- /**
90
- * Scroll la liste
91
- * @param {number} deltaY
92
- */
93
- scroll(deltaY) {
94
- this.scrollOffset += deltaY;
95
- if (this.scrollOffset < 0) this.scrollOffset = 0;
96
- const maxScroll = Math.max(0, this.height - this.viewportHeight);
97
- if (this.scrollOffset > maxScroll) this.scrollOffset = maxScroll;
98
-
99
- this.updateVisibleItems();
100
- }
125
+ // ─────────────────────────────────────────
126
+ // DESSIN
127
+ // ─────────────────────────────────────────
101
128
 
102
- /**
103
- * Dessine les items visibles
104
- * @param {CanvasRenderingContext2D} ctx
105
- */
106
129
  draw(ctx) {
107
- for (let item of this.visibleItems) {
130
+ for (const item of this.visibleItems.values()) {
108
131
  item.draw(ctx);
109
132
  }
110
133
  }
111
134
 
112
- /**
113
- * Toujours false : les ListItems gèrent leurs clics
114
- */
115
135
  isPointInside() {
116
- return false;
136
+ return false; // Les ListItems gèrent leurs propres hit-tests
137
+ }
138
+
139
+ // ─────────────────────────────────────────
140
+ // DESTROY
141
+ // ─────────────────────────────────────────
142
+
143
+ destroy() {
144
+ this.clear();
145
+ super.destroy();
117
146
  }
118
147
  }
119
148
 
120
- export default VirtualList;
149
+ export default VirtualList;