canvasframework 0.3.16 → 0.3.17
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.
- package/components/Button.js +14 -0
- package/components/ImageCarousel.js +100 -74
- package/components/InputTags.js +50 -12
- package/components/List.js +36 -35
- package/components/SwipeableListItem.js +249 -81
- package/components/Tabs.js +183 -53
- package/package.json +1 -1
package/components/Button.js
CHANGED
|
@@ -93,6 +93,14 @@ class Button extends Component {
|
|
|
93
93
|
this.rippleColor = this.hexToRgba(baseColor, 0.3);
|
|
94
94
|
this.elevation = 0;
|
|
95
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;
|
|
103
|
+
break;
|
|
96
104
|
}
|
|
97
105
|
}
|
|
98
106
|
|
|
@@ -127,6 +135,12 @@ class Button extends Component {
|
|
|
127
135
|
this.textColor = options.textColor || baseColor;
|
|
128
136
|
this.borderWidth = 0;
|
|
129
137
|
break;
|
|
138
|
+
|
|
139
|
+
default :
|
|
140
|
+
this.bgColor = 'transparent';
|
|
141
|
+
this.textColor = options.textColor || baseColor;
|
|
142
|
+
this.borderWidth = 0;
|
|
143
|
+
break;
|
|
130
144
|
}
|
|
131
145
|
}
|
|
132
146
|
|
|
@@ -3,22 +3,9 @@ import Component from '../core/Component.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* Carousel / Slider d'images avec swipe horizontal et lazy load
|
|
5
5
|
* Compatible Material et Cupertino
|
|
6
|
-
*
|
|
7
|
-
* @extends Component
|
|
6
|
+
* Tout le scroll est géré par le composant
|
|
8
7
|
*/
|
|
9
8
|
class ImageCarousel extends Component {
|
|
10
|
-
/**
|
|
11
|
-
* @param {CanvasFramework} framework - Framework parent
|
|
12
|
-
* @param {Object} [options={}]
|
|
13
|
-
* @param {Array<string>} [options.images=[]] - URLs des images
|
|
14
|
-
* @param {number} [options.height=200] - Hauteur du carousel
|
|
15
|
-
* @param {number} [options.spacing=16] - Espacement entre images
|
|
16
|
-
* @param {number} [options.borderRadius=8] - Coins arrondis
|
|
17
|
-
* @param {number} [options.pageIndicatorSize=8] - Taille des dots
|
|
18
|
-
* @param {string} [options.pageIndicatorColor='#6200EE'] - Couleur dot actif
|
|
19
|
-
* @param {Function} [options.onSwipeEnd] - Callback quand la page change
|
|
20
|
-
* @param {Function} [options.onImageClick] - Callback clic sur image
|
|
21
|
-
*/
|
|
22
9
|
constructor(framework, options = {}) {
|
|
23
10
|
super(framework, options);
|
|
24
11
|
|
|
@@ -33,8 +20,9 @@ class ImageCarousel extends Component {
|
|
|
33
20
|
this.pageIndicatorColor = options.pageIndicatorColor || '#6200EE';
|
|
34
21
|
|
|
35
22
|
this.platform = framework.platform;
|
|
36
|
-
|
|
23
|
+
|
|
37
24
|
this.isDragging = false;
|
|
25
|
+
this.lastX = 0;
|
|
38
26
|
this.velocity = 0;
|
|
39
27
|
|
|
40
28
|
this.onSwipeEnd = options.onSwipeEnd || null;
|
|
@@ -42,98 +30,140 @@ class ImageCarousel extends Component {
|
|
|
42
30
|
|
|
43
31
|
this.loadedImages = Array(this.images.length).fill(null);
|
|
44
32
|
|
|
45
|
-
|
|
46
|
-
this.framework.addEventListener('touchstart', this.onTouchStart.bind(this));
|
|
47
|
-
this.framework.addEventListener('touchmove', this.onTouchMove.bind(this));
|
|
48
|
-
this.framework.addEventListener('touchend', this.onTouchEnd.bind(this));
|
|
49
|
-
|
|
33
|
+
this._setupEventHandlers();
|
|
50
34
|
this.animateScroll();
|
|
51
35
|
}
|
|
52
36
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
37
|
+
// --------------------------
|
|
38
|
+
// Event handlers
|
|
39
|
+
// --------------------------
|
|
40
|
+
_setupEventHandlers() {
|
|
41
|
+
const canvas = this.framework.canvas;
|
|
42
|
+
|
|
43
|
+
// TOUCH
|
|
44
|
+
canvas.addEventListener('touchstart', (e) => {
|
|
45
|
+
if (e.touches.length === 1 && this.isPointInsideTouch(e.touches[0])) {
|
|
46
|
+
this.isDragging = true;
|
|
47
|
+
this.lastX = e.touches[0].clientX;
|
|
48
|
+
this.velocity = 0;
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
canvas.addEventListener('touchmove', (e) => {
|
|
54
|
+
if (this.isDragging && e.touches.length === 1) {
|
|
55
|
+
const delta = e.touches[0].clientX - this.lastX;
|
|
56
|
+
this.scrollX += delta;
|
|
57
|
+
this.velocity = delta;
|
|
58
|
+
this.lastX = e.touches[0].clientX;
|
|
59
|
+
|
|
60
|
+
this._clampScroll();
|
|
61
|
+
this._requestRedraw();
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
61
65
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
66
|
+
canvas.addEventListener('touchend', () => this._endDrag());
|
|
67
|
+
|
|
68
|
+
// MOUSE
|
|
69
|
+
canvas.addEventListener('mousedown', (e) => {
|
|
70
|
+
if (this.isPointInside(e)) {
|
|
71
|
+
this.isDragging = true;
|
|
72
|
+
this.lastX = e.clientX;
|
|
73
|
+
this.velocity = 0;
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
canvas.addEventListener('mousemove', (e) => {
|
|
79
|
+
if (this.isDragging) {
|
|
80
|
+
const delta = e.clientX - this.lastX;
|
|
81
|
+
this.scrollX += delta;
|
|
82
|
+
this.velocity = delta;
|
|
83
|
+
this.lastX = e.clientX;
|
|
84
|
+
|
|
85
|
+
this._clampScroll();
|
|
86
|
+
this._requestRedraw();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
75
89
|
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
canvas.addEventListener('mouseup', () => this._endDrag());
|
|
91
|
+
canvas.addEventListener('mouseleave', () => this._endDrag());
|
|
92
|
+
}
|
|
78
93
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
94
|
+
_endDrag() {
|
|
95
|
+
if (this.isDragging) {
|
|
96
|
+
this.isDragging = false;
|
|
97
|
+
// Snap à la page la plus proche
|
|
98
|
+
const targetIndex = Math.round(-this.scrollX / (this.width + this.spacing));
|
|
99
|
+
this.currentIndex = Math.min(Math.max(targetIndex, 0), this.images.length - 1);
|
|
100
|
+
this.scrollX = -this.currentIndex * (this.width + this.spacing);
|
|
82
101
|
|
|
83
|
-
|
|
84
|
-
else if (this.velocity > 0.5) targetIndex = Math.max(this.currentIndex - 1, 0);
|
|
85
|
-
else {
|
|
86
|
-
const deltaIndex = Math.round(-this.scrollX / (this.width + this.spacing)) - this.currentIndex;
|
|
87
|
-
if (deltaIndex > 0) targetIndex = Math.min(this.currentIndex + 1, this.images.length - 1);
|
|
88
|
-
else if (deltaIndex < 0) targetIndex = Math.max(this.currentIndex - 1, 0);
|
|
102
|
+
if (this.onSwipeEnd) this.onSwipeEnd(this.currentIndex);
|
|
89
103
|
}
|
|
104
|
+
}
|
|
90
105
|
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
_clampScroll() {
|
|
107
|
+
const maxScroll = 0;
|
|
108
|
+
const minScroll = -(this.images.length - 1) * (this.width + this.spacing);
|
|
109
|
+
if (this.scrollX > maxScroll) this.scrollX = maxScroll;
|
|
110
|
+
if (this.scrollX < minScroll) this.scrollX = minScroll;
|
|
111
|
+
}
|
|
93
112
|
|
|
94
|
-
|
|
95
|
-
|
|
113
|
+
isPointInsideTouch(touch) {
|
|
114
|
+
const rect = this.framework.canvas.getBoundingClientRect();
|
|
115
|
+
const x = touch.clientX - rect.left;
|
|
116
|
+
const y = touch.clientY - rect.top;
|
|
117
|
+
return this.isPointInside(x, y);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
isPointInside(x, y) {
|
|
121
|
+
return x >= this.x && x <= this.x + this.width &&
|
|
122
|
+
y >= this.y && y <= this.y + this.height;
|
|
123
|
+
}
|
|
96
124
|
|
|
97
|
-
|
|
125
|
+
_requestRedraw() {
|
|
126
|
+
if (this.framework.markComponentDirty) this.framework.markComponentDirty(this);
|
|
98
127
|
}
|
|
99
128
|
|
|
129
|
+
// --------------------------
|
|
130
|
+
// Animation / Inertie
|
|
131
|
+
// --------------------------
|
|
100
132
|
animateScroll() {
|
|
101
133
|
const animate = () => {
|
|
102
134
|
if (!this.isDragging) {
|
|
103
|
-
// inertia
|
|
135
|
+
// inertia
|
|
104
136
|
if (Math.abs(this.velocity) > 0.1) {
|
|
105
137
|
this.scrollX += this.velocity;
|
|
106
|
-
this.velocity *= 0.95;
|
|
138
|
+
this.velocity *= 0.95;
|
|
107
139
|
|
|
108
|
-
|
|
109
|
-
if (this.scrollX > 0) this.scrollX = 0;
|
|
110
|
-
const maxScroll = -(this.images.length - 1) * (this.width + this.spacing);
|
|
111
|
-
if (this.scrollX < maxScroll) this.scrollX = maxScroll;
|
|
140
|
+
this._clampScroll();
|
|
112
141
|
} else {
|
|
113
|
-
// snap
|
|
142
|
+
// snap doux vers la page
|
|
114
143
|
const target = -this.currentIndex * (this.width + this.spacing);
|
|
115
144
|
this.scrollX += (target - this.scrollX) * 0.2;
|
|
116
145
|
}
|
|
117
146
|
}
|
|
118
|
-
|
|
119
147
|
requestAnimationFrame(animate);
|
|
120
148
|
};
|
|
121
149
|
animate();
|
|
122
150
|
}
|
|
123
151
|
|
|
152
|
+
// --------------------------
|
|
153
|
+
// Draw
|
|
154
|
+
// --------------------------
|
|
124
155
|
draw(ctx) {
|
|
125
156
|
ctx.save();
|
|
126
|
-
|
|
127
157
|
const startX = this.x + this.scrollX + this.spacing / 2;
|
|
128
158
|
|
|
129
159
|
for (let i = 0; i < this.images.length; i++) {
|
|
130
160
|
const imgX = startX + i * (this.width + this.spacing);
|
|
131
161
|
|
|
132
|
-
//
|
|
162
|
+
// lazy load
|
|
133
163
|
if (!this.loadedImages[i]) {
|
|
134
164
|
const img = new Image();
|
|
135
165
|
img.src = this.images[i];
|
|
136
|
-
img.onload = () => { this.loadedImages[i] = img; };
|
|
166
|
+
img.onload = () => { this.loadedImages[i] = img; this._requestRedraw(); };
|
|
137
167
|
}
|
|
138
168
|
|
|
139
169
|
ctx.save();
|
|
@@ -152,10 +182,11 @@ class ImageCarousel extends Component {
|
|
|
152
182
|
ctx.textBaseline = 'middle';
|
|
153
183
|
ctx.fillText('🖼', imgX + this.width / 2, this.y + this.height / 2);
|
|
154
184
|
}
|
|
185
|
+
|
|
155
186
|
ctx.restore();
|
|
156
187
|
}
|
|
157
188
|
|
|
158
|
-
//
|
|
189
|
+
// pagination Material
|
|
159
190
|
if (this.platform === 'material') {
|
|
160
191
|
const dotY = this.y + this.height + 12;
|
|
161
192
|
const totalWidth = this.images.length * this.pageIndicatorSize * 2;
|
|
@@ -183,11 +214,6 @@ class ImageCarousel extends Component {
|
|
|
183
214
|
ctx.lineTo(x, y + radius);
|
|
184
215
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
|
185
216
|
}
|
|
186
|
-
|
|
187
|
-
isPointInside(x, y) {
|
|
188
|
-
return x >= this.x && x <= this.x + this.width &&
|
|
189
|
-
y >= this.y && y <= this.y + this.height;
|
|
190
|
-
}
|
|
191
217
|
}
|
|
192
218
|
|
|
193
219
|
export default ImageCarousel;
|
package/components/InputTags.js
CHANGED
|
@@ -414,14 +414,44 @@ class InputTags extends Component {
|
|
|
414
414
|
|
|
415
415
|
return cursorX;
|
|
416
416
|
}
|
|
417
|
+
|
|
418
|
+
updateHeight() {
|
|
419
|
+
const maxX = this.x + this.width - 10; // marge droite
|
|
420
|
+
let currentX = this.x + 10;
|
|
421
|
+
let currentY = this.y + 10;
|
|
422
|
+
let lines = 1;
|
|
423
|
+
|
|
424
|
+
for (let i = 0; i < this.tags.length; i++) {
|
|
425
|
+
const tagWidth = this.measureTagWidth(this.tags[i]);
|
|
426
|
+
|
|
427
|
+
if (currentX + tagWidth > maxX) {
|
|
428
|
+
currentX = this.x + 10;
|
|
429
|
+
currentY += this.tagHeight + this.tagSpacing;
|
|
430
|
+
lines++;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
currentX += tagWidth + this.tagSpacing;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Ajouter la largeur du texte en cours
|
|
437
|
+
const textWidth = this.value.length * (this.fontSize * 0.6);
|
|
438
|
+
if (currentX + textWidth > maxX) {
|
|
439
|
+
lines++;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Mettre à jour la hauteur dynamique
|
|
443
|
+
this.height = Math.max(lines * (this.tagHeight + this.tagSpacing) + 10, 40); // 40 = minHeight
|
|
444
|
+
}
|
|
417
445
|
|
|
418
446
|
/**
|
|
419
447
|
* Dessine l'input
|
|
420
448
|
* @param {CanvasRenderingContext2D} ctx - Contexte de dessin
|
|
421
449
|
*/
|
|
422
450
|
draw(ctx) {
|
|
451
|
+
this.updateHeight();
|
|
423
452
|
ctx.save();
|
|
424
|
-
|
|
453
|
+
|
|
454
|
+
// Dessin du contour
|
|
425
455
|
if (this.platform === 'material') {
|
|
426
456
|
ctx.strokeStyle = this.focused ? '#6200EE' : '#CCCCCC';
|
|
427
457
|
ctx.lineWidth = this.focused ? 2 : 1;
|
|
@@ -436,30 +466,38 @@ class InputTags extends Component {
|
|
|
436
466
|
this.roundRect(ctx, this.x, this.y, this.width, this.height, 8);
|
|
437
467
|
ctx.stroke();
|
|
438
468
|
}
|
|
439
|
-
|
|
469
|
+
|
|
440
470
|
// Position de départ pour dessiner les tags
|
|
441
471
|
let currentX = this.x + 10;
|
|
442
|
-
|
|
443
|
-
|
|
472
|
+
let currentY = this.y + 10;
|
|
473
|
+
const maxX = this.x + this.width - 10;
|
|
474
|
+
|
|
444
475
|
// Dessiner les tags
|
|
445
476
|
for (let i = 0; i < this.tags.length; i++) {
|
|
446
|
-
this.drawTag(ctx, this.tags[i], currentX, tagY);
|
|
447
477
|
const tagWidth = this.measureTagWidth(this.tags[i]);
|
|
478
|
+
|
|
479
|
+
// Retour à la ligne si dépassement
|
|
480
|
+
if (currentX + tagWidth > maxX) {
|
|
481
|
+
currentX = this.x + 10;
|
|
482
|
+
currentY += this.tagHeight + this.tagSpacing;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
this.drawTag(ctx, this.tags[i], currentX, currentY);
|
|
448
486
|
currentX += tagWidth + this.tagSpacing;
|
|
449
487
|
}
|
|
450
|
-
|
|
451
|
-
// Dessiner le texte en cours
|
|
488
|
+
|
|
489
|
+
// Dessiner le texte en cours ou placeholder
|
|
452
490
|
if (this.value || (this.focused && this.tags.length === 0)) {
|
|
453
491
|
ctx.fillStyle = this.value ? '#000000' : '#999999';
|
|
454
492
|
ctx.font = `${this.fontSize}px -apple-system, BlinkMacSystemFont, 'Roboto', sans-serif`;
|
|
455
493
|
ctx.textAlign = 'left';
|
|
456
494
|
ctx.textBaseline = 'middle';
|
|
457
|
-
|
|
495
|
+
|
|
458
496
|
const displayText = this.value || this.placeholder;
|
|
459
|
-
const textY =
|
|
460
|
-
|
|
497
|
+
const textY = currentY + this.tagHeight / 2;
|
|
498
|
+
|
|
461
499
|
ctx.fillText(displayText, currentX, textY);
|
|
462
|
-
|
|
500
|
+
|
|
463
501
|
// Curseur
|
|
464
502
|
if (this.focused && this.cursorVisible) {
|
|
465
503
|
const textWidth = ctx.measureText(this.value).width;
|
|
@@ -467,7 +505,7 @@ class InputTags extends Component {
|
|
|
467
505
|
ctx.fillRect(currentX + textWidth, textY - this.fontSize / 2, 2, this.fontSize);
|
|
468
506
|
}
|
|
469
507
|
}
|
|
470
|
-
|
|
508
|
+
|
|
471
509
|
ctx.restore();
|
|
472
510
|
}
|
|
473
511
|
|
package/components/List.js
CHANGED
|
@@ -1,24 +1,12 @@
|
|
|
1
1
|
import Component from '../core/Component.js';
|
|
2
2
|
import ListItem from '../components/ListItem.js';
|
|
3
|
+
|
|
3
4
|
/**
|
|
4
5
|
* Conteneur pour les éléments de liste (ListItems) avec défilement automatique
|
|
5
6
|
* @class
|
|
6
7
|
* @extends Component
|
|
7
|
-
* @param {Framework} framework - Instance du framework
|
|
8
|
-
* @param {Object} [options={}] - Options de configuration
|
|
9
|
-
* @param {number} [options.itemHeight=56] - Hauteur de chaque item en pixels
|
|
10
|
-
* @param {Function} [options.onItemClick] - Callback appelé lors du clic sur un item
|
|
11
|
-
* @param {number} [options.y=0] - Position Y de départ
|
|
12
|
-
* @example
|
|
13
|
-
* const list = new List(framework, {
|
|
14
|
-
* itemHeight: 64,
|
|
15
|
-
* onItemClick: (index, itemOptions) => console.log('Item clicked:', index)
|
|
16
|
-
* });
|
|
17
8
|
*/
|
|
18
9
|
class List extends Component {
|
|
19
|
-
/**
|
|
20
|
-
* @constructs List
|
|
21
|
-
*/
|
|
22
10
|
constructor(framework, options = {}) {
|
|
23
11
|
super(framework, options);
|
|
24
12
|
/** @type {ListItem[]} */
|
|
@@ -28,37 +16,50 @@ class List extends Component {
|
|
|
28
16
|
/** @type {Function|undefined} */
|
|
29
17
|
this.onItemClick = options.onItemClick;
|
|
30
18
|
/** @type {number} */
|
|
31
|
-
this.y = options.y || 0;
|
|
19
|
+
this.y = options.y || 0;
|
|
32
20
|
}
|
|
33
21
|
|
|
34
22
|
/**
|
|
35
23
|
* Ajoute un item à la liste
|
|
36
|
-
* @param {Object} itemOptions - Options pour l'item
|
|
37
|
-
* @
|
|
38
|
-
* @param {Function} [itemOptions.onClick] - Callback spécifique à l'item
|
|
39
|
-
* @param {Object} [itemOptions.style] - Style optionnel pour l'item
|
|
40
|
-
* @returns {ListItem} L'item créé
|
|
24
|
+
* @param {Object|Component} itemOptions - Options pour l'item OU instance de Component (ex: SwipeableListItem)
|
|
25
|
+
* @returns {ListItem|Component} L'item créé ou passé
|
|
41
26
|
*/
|
|
42
27
|
addItem(itemOptions) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
28
|
+
let item;
|
|
29
|
+
|
|
30
|
+
// 🔹 Vérifier si c'est déjà un composant instancié (SwipeableListItem)
|
|
31
|
+
if (itemOptions instanceof Component) {
|
|
32
|
+
item = itemOptions;
|
|
33
|
+
|
|
34
|
+
// 🔹 CRITIQUE : Définir la position et taille de l'item
|
|
35
|
+
item.x = this.x;
|
|
36
|
+
item.y = this.y + (this.items.length * this.itemHeight);
|
|
37
|
+
item.width = this.width;
|
|
38
|
+
item.height = this.itemHeight;
|
|
39
|
+
|
|
40
|
+
console.log(`📦 SwipeableListItem positionné à y=${item.y}`);
|
|
41
|
+
} else {
|
|
42
|
+
// Créer un ListItem standard
|
|
43
|
+
item = new ListItem(this.framework, {
|
|
44
|
+
...itemOptions,
|
|
45
|
+
x: this.x,
|
|
46
|
+
y: this.y + (this.items.length * this.itemHeight),
|
|
47
|
+
width: this.width,
|
|
48
|
+
height: this.itemHeight,
|
|
49
|
+
onClick: () => {
|
|
50
|
+
if (this.onItemClick) {
|
|
51
|
+
this.onItemClick(this.items.length, itemOptions);
|
|
52
|
+
}
|
|
53
|
+
if (itemOptions.onClick) {
|
|
54
|
+
itemOptions.onClick();
|
|
55
|
+
}
|
|
55
56
|
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
58
59
|
|
|
59
60
|
this.items.push(item);
|
|
60
|
-
this.framework.add(item);
|
|
61
|
-
this.height = this.items.length * this.itemHeight;
|
|
61
|
+
this.framework.add(item);
|
|
62
|
+
this.height = this.items.length * this.itemHeight;
|
|
62
63
|
|
|
63
64
|
return item;
|
|
64
65
|
}
|
|
@@ -4,13 +4,6 @@ import Component from '../core/Component.js';
|
|
|
4
4
|
* Élément de liste avec support swipe et actions
|
|
5
5
|
* @class
|
|
6
6
|
* @extends Component
|
|
7
|
-
* @property {string} title - Titre
|
|
8
|
-
* @property {string} subtitle - Sous-titre
|
|
9
|
-
* @property {Array<{icon?: string, text: string, color: string, onClick: Function}>} leftActions - Actions swipe gauche
|
|
10
|
-
* @property {Array<{icon?: string, text: string, color: string, onClick: Function}>} rightActions - Actions swipe droite
|
|
11
|
-
* @property {string} bgColor - Couleur de fond
|
|
12
|
-
* @property {string} platform - Plateforme ('material' ou 'cupertino')
|
|
13
|
-
* @property {number} height - Hauteur de l'item
|
|
14
7
|
*/
|
|
15
8
|
class SwipeableListItem extends Component {
|
|
16
9
|
constructor(framework, options = {}) {
|
|
@@ -29,9 +22,180 @@ class SwipeableListItem extends Component {
|
|
|
29
22
|
this.dragOffset = 0;
|
|
30
23
|
this.dragging = false;
|
|
31
24
|
this.startX = 0;
|
|
25
|
+
this.startY = 0;
|
|
26
|
+
this.hasMoved = false;
|
|
32
27
|
|
|
33
28
|
this.ripples = [];
|
|
34
|
-
this.
|
|
29
|
+
this.animationFrame = null;
|
|
30
|
+
this.lastAnimationTime = 0;
|
|
31
|
+
|
|
32
|
+
// 🔹 Binding des handlers
|
|
33
|
+
this.handleMouseDown = this.handleMouseDown.bind(this);
|
|
34
|
+
this.handleMouseMove = this.handleMouseMove.bind(this);
|
|
35
|
+
this.handleMouseUp = this.handleMouseUp.bind(this);
|
|
36
|
+
this.handleTouchStart = this.handleTouchStart.bind(this);
|
|
37
|
+
this.handleTouchMove = this.handleTouchMove.bind(this);
|
|
38
|
+
this.handleTouchEnd = this.handleTouchEnd.bind(this);
|
|
39
|
+
|
|
40
|
+
console.log('🏗️ SwipeableListItem créé', {
|
|
41
|
+
hasCanvas: !!this.framework.canvas,
|
|
42
|
+
x: this.x,
|
|
43
|
+
y: this.y,
|
|
44
|
+
width: this.width,
|
|
45
|
+
height: this.height
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// 🔹 Enregistrer les événements
|
|
49
|
+
this.setupEventListeners();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Configure les écouteurs d'événements
|
|
54
|
+
* @private
|
|
55
|
+
*/
|
|
56
|
+
setupEventListeners() {
|
|
57
|
+
if (!this.framework.canvas) {
|
|
58
|
+
console.error('❌ Pas de canvas trouvé dans le framework');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const canvas = this.framework.canvas;
|
|
63
|
+
console.log('📡 Configuration des événements sur le canvas');
|
|
64
|
+
|
|
65
|
+
// Événements souris
|
|
66
|
+
canvas.addEventListener('mousedown', this.handleMouseDown);
|
|
67
|
+
canvas.addEventListener('mousemove', this.handleMouseMove);
|
|
68
|
+
canvas.addEventListener('mouseup', this.handleMouseUp);
|
|
69
|
+
|
|
70
|
+
// Événements tactiles
|
|
71
|
+
canvas.addEventListener('touchstart', this.handleTouchStart, { passive: false });
|
|
72
|
+
canvas.addEventListener('touchmove', this.handleTouchMove, { passive: false });
|
|
73
|
+
canvas.addEventListener('touchend', this.handleTouchEnd);
|
|
74
|
+
|
|
75
|
+
console.log('✅ Événements configurés');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Nettoyer les événements lors de la destruction
|
|
80
|
+
*/
|
|
81
|
+
destroy() {
|
|
82
|
+
if (this.framework.canvas) {
|
|
83
|
+
const canvas = this.framework.canvas;
|
|
84
|
+
canvas.removeEventListener('mousedown', this.handleMouseDown);
|
|
85
|
+
canvas.removeEventListener('mousemove', this.handleMouseMove);
|
|
86
|
+
canvas.removeEventListener('mouseup', this.handleMouseUp);
|
|
87
|
+
canvas.removeEventListener('touchstart', this.handleTouchStart);
|
|
88
|
+
canvas.removeEventListener('touchmove', this.handleTouchMove);
|
|
89
|
+
canvas.removeEventListener('touchend', this.handleTouchEnd);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (this.animationFrame) {
|
|
93
|
+
cancelAnimationFrame(this.animationFrame);
|
|
94
|
+
this.animationFrame = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (super.destroy) {
|
|
98
|
+
super.destroy();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Obtenir les coordonnées depuis un événement
|
|
104
|
+
* @private
|
|
105
|
+
*/
|
|
106
|
+
getCoordinates(event) {
|
|
107
|
+
const rect = this.framework.canvas.getBoundingClientRect();
|
|
108
|
+
if (event.touches && event.touches.length > 0) {
|
|
109
|
+
return {
|
|
110
|
+
x: event.touches[0].clientX - rect.left,
|
|
111
|
+
y: event.touches[0].clientY - rect.top
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
x: event.clientX - rect.left,
|
|
116
|
+
y: event.clientY - rect.top
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Handler mousedown
|
|
122
|
+
* @private
|
|
123
|
+
*/
|
|
124
|
+
handleMouseDown(event) {
|
|
125
|
+
const coords = this.getCoordinates(event);
|
|
126
|
+
console.log('🖱️ MouseDown', coords, 'isInside:', this.isPointInside(coords.x, coords.y));
|
|
127
|
+
this.onDragStart(coords.x, coords.y);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Handler mousemove
|
|
132
|
+
* @private
|
|
133
|
+
*/
|
|
134
|
+
handleMouseMove(event) {
|
|
135
|
+
if (!this.dragging) return;
|
|
136
|
+
const coords = this.getCoordinates(event);
|
|
137
|
+
console.log('🖱️ MouseMove', coords, 'dragging:', this.dragging);
|
|
138
|
+
this.onDragMove(coords.x, coords.y);
|
|
139
|
+
this.requestRender();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Handler mouseup
|
|
144
|
+
* @private
|
|
145
|
+
*/
|
|
146
|
+
handleMouseUp(event) {
|
|
147
|
+
if (!this.dragging) return;
|
|
148
|
+
console.log('🖱️ MouseUp');
|
|
149
|
+
this.onDragEnd();
|
|
150
|
+
this.requestRender();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Handler touchstart
|
|
155
|
+
* @private
|
|
156
|
+
*/
|
|
157
|
+
handleTouchStart(event) {
|
|
158
|
+
const coords = this.getCoordinates(event);
|
|
159
|
+
console.log('👆 TouchStart', coords, 'isInside:', this.isPointInside(coords.x, coords.y));
|
|
160
|
+
if (this.isPointInside(coords.x, coords.y)) {
|
|
161
|
+
event.preventDefault();
|
|
162
|
+
this.onDragStart(coords.x, coords.y);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Handler touchmove
|
|
168
|
+
* @private
|
|
169
|
+
*/
|
|
170
|
+
handleTouchMove(event) {
|
|
171
|
+
if (!this.dragging) return;
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
const coords = this.getCoordinates(event);
|
|
174
|
+
console.log('👆 TouchMove', coords);
|
|
175
|
+
this.onDragMove(coords.x, coords.y);
|
|
176
|
+
this.requestRender();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Handler touchend
|
|
181
|
+
* @private
|
|
182
|
+
*/
|
|
183
|
+
handleTouchEnd(event) {
|
|
184
|
+
if (!this.dragging) return;
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
console.log('👆 TouchEnd');
|
|
187
|
+
this.onDragEnd();
|
|
188
|
+
this.requestRender();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Demander un redessin
|
|
193
|
+
* @private
|
|
194
|
+
*/
|
|
195
|
+
requestRender() {
|
|
196
|
+
if (this.framework && this.framework.requestRender) {
|
|
197
|
+
this.framework.requestRender();
|
|
198
|
+
}
|
|
35
199
|
}
|
|
36
200
|
|
|
37
201
|
/**
|
|
@@ -40,21 +204,46 @@ class SwipeableListItem extends Component {
|
|
|
40
204
|
* @param {number} y
|
|
41
205
|
*/
|
|
42
206
|
onDragStart(x, y) {
|
|
43
|
-
|
|
207
|
+
const inside = this.isPointInside(x, y);
|
|
208
|
+
console.log('🟢 onDragStart', { x, y, inside, bounds: { x: this.x, y: this.y, w: this.width, h: this.height } });
|
|
209
|
+
|
|
210
|
+
if (inside) {
|
|
44
211
|
this.dragging = true;
|
|
45
212
|
this.startX = x;
|
|
213
|
+
this.startY = y;
|
|
214
|
+
this.hasMoved = false;
|
|
215
|
+
console.log('✅ Swipe démarré');
|
|
216
|
+
} else {
|
|
217
|
+
console.log('❌ Point en dehors des limites');
|
|
46
218
|
}
|
|
47
219
|
}
|
|
48
220
|
|
|
49
221
|
/**
|
|
50
222
|
* Déplacement pendant swipe
|
|
51
223
|
* @param {number} x
|
|
224
|
+
* @param {number} y
|
|
52
225
|
*/
|
|
53
|
-
onDragMove(x) {
|
|
226
|
+
onDragMove(x, y) {
|
|
54
227
|
if (this.dragging) {
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
228
|
+
const deltaX = x - this.startX;
|
|
229
|
+
const deltaY = Math.abs(y - this.startY);
|
|
230
|
+
|
|
231
|
+
console.log('🔄 onDragMove', { deltaX, deltaY, hasMoved: this.hasMoved });
|
|
232
|
+
|
|
233
|
+
// Swipe horizontal seulement si déplacement > 5px
|
|
234
|
+
if (Math.abs(deltaX) > 5 || this.hasMoved) {
|
|
235
|
+
this.hasMoved = true;
|
|
236
|
+
this.dragOffset = deltaX;
|
|
237
|
+
|
|
238
|
+
// Limiter le déplacement
|
|
239
|
+
const maxOffset = Math.max(
|
|
240
|
+
this.leftActions.length * 80,
|
|
241
|
+
this.rightActions.length * 80
|
|
242
|
+
);
|
|
243
|
+
this.dragOffset = Math.min(Math.max(this.dragOffset, -maxOffset), maxOffset);
|
|
244
|
+
|
|
245
|
+
console.log('↔️ Offset mis à jour:', this.dragOffset);
|
|
246
|
+
}
|
|
58
247
|
}
|
|
59
248
|
}
|
|
60
249
|
|
|
@@ -63,51 +252,24 @@ class SwipeableListItem extends Component {
|
|
|
63
252
|
*/
|
|
64
253
|
onDragEnd() {
|
|
65
254
|
if (this.dragging) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
this.
|
|
255
|
+
console.log('🔴 onDragEnd', { offset: this.dragOffset, hasMoved: this.hasMoved });
|
|
256
|
+
|
|
257
|
+
if (this.hasMoved) {
|
|
258
|
+
if (this.dragOffset > 80 && this.leftActions[0]) {
|
|
259
|
+
console.log('✅ Action gauche déclenchée');
|
|
260
|
+
this.leftActions[0].onClick?.();
|
|
261
|
+
} else if (this.dragOffset < -80 && this.rightActions[0]) {
|
|
262
|
+
console.log('✅ Action droite déclenchée');
|
|
263
|
+
this.rightActions[0].onClick?.();
|
|
264
|
+
}
|
|
70
265
|
}
|
|
266
|
+
|
|
71
267
|
this.dragOffset = 0;
|
|
72
268
|
this.dragging = false;
|
|
269
|
+
this.hasMoved = false;
|
|
73
270
|
}
|
|
74
271
|
}
|
|
75
272
|
|
|
76
|
-
/**
|
|
77
|
-
* Gestion du press pour ripple Material
|
|
78
|
-
* @param {number} x
|
|
79
|
-
* @param {number} y
|
|
80
|
-
*/
|
|
81
|
-
handlePress(x, y) {
|
|
82
|
-
if (this.platform === 'material') {
|
|
83
|
-
const adjustedY = y - this.framework.scrollOffset;
|
|
84
|
-
this.ripples.push({
|
|
85
|
-
x: x - this.x,
|
|
86
|
-
y: adjustedY - this.y,
|
|
87
|
-
radius: 0,
|
|
88
|
-
maxRadius: Math.max(this.width, this.height) * 1.5,
|
|
89
|
-
opacity: 1
|
|
90
|
-
});
|
|
91
|
-
this.animateRipple();
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
animateRipple() {
|
|
96
|
-
const animate = () => {
|
|
97
|
-
let hasActive = false;
|
|
98
|
-
for (let ripple of this.ripples) {
|
|
99
|
-
if (ripple.radius < ripple.maxRadius) {
|
|
100
|
-
ripple.radius += ripple.maxRadius / 15;
|
|
101
|
-
hasActive = true;
|
|
102
|
-
}
|
|
103
|
-
if (ripple.radius >= ripple.maxRadius * 0.5) ripple.opacity -= 0.05;
|
|
104
|
-
}
|
|
105
|
-
this.ripples = this.ripples.filter(r => r.opacity > 0);
|
|
106
|
-
if (hasActive) requestAnimationFrame(animate);
|
|
107
|
-
};
|
|
108
|
-
animate();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
273
|
/**
|
|
112
274
|
* Dessine l'item
|
|
113
275
|
* @param {CanvasRenderingContext2D} ctx
|
|
@@ -115,12 +277,12 @@ class SwipeableListItem extends Component {
|
|
|
115
277
|
draw(ctx) {
|
|
116
278
|
ctx.save();
|
|
117
279
|
|
|
118
|
-
// Actions swipe
|
|
280
|
+
// Actions swipe (arrière-plan)
|
|
119
281
|
if (this.dragOffset > 0 && this.leftActions[0]) {
|
|
120
282
|
ctx.fillStyle = this.leftActions[0].color || '#388E3C';
|
|
121
283
|
ctx.fillRect(this.x, this.y, this.dragOffset, this.height);
|
|
122
284
|
ctx.fillStyle = '#FFF';
|
|
123
|
-
ctx.font = '14px sans-serif';
|
|
285
|
+
ctx.font = 'bold 14px sans-serif';
|
|
124
286
|
ctx.textAlign = 'center';
|
|
125
287
|
ctx.textBaseline = 'middle';
|
|
126
288
|
ctx.fillText(this.leftActions[0].text, this.x + this.dragOffset / 2, this.y + this.height / 2);
|
|
@@ -129,51 +291,57 @@ class SwipeableListItem extends Component {
|
|
|
129
291
|
ctx.fillStyle = this.rightActions[0].color || '#D32F2F';
|
|
130
292
|
ctx.fillRect(this.x + this.width - offset, this.y, offset, this.height);
|
|
131
293
|
ctx.fillStyle = '#FFF';
|
|
294
|
+
ctx.font = 'bold 14px sans-serif';
|
|
132
295
|
ctx.textAlign = 'center';
|
|
296
|
+
ctx.textBaseline = 'middle';
|
|
133
297
|
ctx.fillText(this.rightActions[0].text, this.x + this.width - offset / 2, this.y + this.height / 2);
|
|
134
298
|
}
|
|
135
299
|
|
|
300
|
+
// Déplacement du contenu principal
|
|
136
301
|
ctx.translate(this.dragOffset, 0);
|
|
137
302
|
|
|
138
303
|
// Background
|
|
139
304
|
ctx.fillStyle = this.bgColor;
|
|
140
305
|
ctx.fillRect(this.x, this.y, this.width, this.height);
|
|
141
306
|
|
|
142
|
-
//
|
|
143
|
-
if (this.platform === 'material') {
|
|
144
|
-
ctx.save();
|
|
145
|
-
ctx.beginPath();
|
|
146
|
-
ctx.rect(this.x, this.y, this.width, this.height);
|
|
147
|
-
ctx.clip();
|
|
148
|
-
for (let ripple of this.ripples) {
|
|
149
|
-
ctx.globalAlpha = ripple.opacity;
|
|
150
|
-
ctx.fillStyle = 'rgba(0,0,0,0.1)';
|
|
151
|
-
ctx.beginPath();
|
|
152
|
-
ctx.arc(this.x + ripple.x, this.y + ripple.y, ripple.radius, 0, Math.PI * 2);
|
|
153
|
-
ctx.fill();
|
|
154
|
-
}
|
|
155
|
-
ctx.restore();
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Texte
|
|
307
|
+
// Texte principal
|
|
159
308
|
ctx.fillStyle = '#000';
|
|
160
|
-
ctx.font = '16px sans-serif';
|
|
309
|
+
ctx.font = '16px -apple-system, Roboto, sans-serif';
|
|
161
310
|
ctx.textAlign = 'left';
|
|
162
|
-
ctx.textBaseline = 'middle';
|
|
163
|
-
|
|
311
|
+
ctx.textBaseline = this.subtitle ? 'top' : 'middle';
|
|
312
|
+
const titleY = this.subtitle ? this.y + 16 : this.y + this.height / 2;
|
|
313
|
+
ctx.fillText(this.title, this.x + 16, titleY);
|
|
314
|
+
|
|
315
|
+
// Sous-titre
|
|
164
316
|
if (this.subtitle) {
|
|
165
317
|
ctx.fillStyle = '#757575';
|
|
166
|
-
ctx.font = '14px sans-serif';
|
|
167
|
-
ctx.fillText(this.subtitle, this.x + 16, this.y +
|
|
318
|
+
ctx.font = '14px -apple-system, Roboto, sans-serif';
|
|
319
|
+
ctx.fillText(this.subtitle, this.x + 16, this.y + 38);
|
|
168
320
|
}
|
|
169
321
|
|
|
322
|
+
// Bordure inférieure
|
|
323
|
+
ctx.strokeStyle = '#E0E0E0';
|
|
324
|
+
ctx.lineWidth = 1;
|
|
325
|
+
ctx.beginPath();
|
|
326
|
+
ctx.moveTo(this.x, this.y + this.height);
|
|
327
|
+
ctx.lineTo(this.x + this.width, this.y + this.height);
|
|
328
|
+
ctx.stroke();
|
|
329
|
+
|
|
170
330
|
ctx.restore();
|
|
171
331
|
}
|
|
172
332
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
333
|
+
isPointInside(x, y) {
|
|
334
|
+
const inside = x >= this.x && x <= this.x + this.width &&
|
|
335
|
+
y >= this.y && y <= this.y + this.height;
|
|
336
|
+
|
|
337
|
+
console.log('🎯 isPointInside check', {
|
|
338
|
+
point: { x, y },
|
|
339
|
+
bounds: { x: this.x, y: this.y, width: this.width, height: this.height },
|
|
340
|
+
inside
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
return inside;
|
|
344
|
+
}
|
|
177
345
|
}
|
|
178
346
|
|
|
179
|
-
export default SwipeableListItem;
|
|
347
|
+
export default SwipeableListItem;
|
package/components/Tabs.js
CHANGED
|
@@ -1,32 +1,14 @@
|
|
|
1
1
|
import Component from '../core/Component.js';
|
|
2
|
+
|
|
2
3
|
/**
|
|
3
|
-
* Onglets de navigation
|
|
4
|
+
* Onglets de navigation avec support Material & Cupertino
|
|
4
5
|
* @class
|
|
5
6
|
* @extends Component
|
|
6
|
-
* @property {Array} tabs - Onglets [{label, icon}]
|
|
7
|
-
* @property {number} selectedIndex - Onglet sélectionné
|
|
8
|
-
* @property {Function} onChange - Callback au changement
|
|
9
|
-
* @property {string} platform - Plateforme
|
|
10
|
-
* @property {string} indicatorColor - Couleur de l'indicateur
|
|
11
|
-
* @property {string} textColor - Couleur du texte
|
|
12
|
-
* @property {string} selectedTextColor - Couleur du texte sélectionné
|
|
13
7
|
*/
|
|
14
8
|
class Tabs extends Component {
|
|
15
|
-
/**
|
|
16
|
-
* Crée une instance de Tabs
|
|
17
|
-
* @param {CanvasFramework} framework - Framework parent
|
|
18
|
-
* @param {Object} [options={}] - Options de configuration
|
|
19
|
-
* @param {Array} [options.tabs=[]] - Onglets
|
|
20
|
-
* @param {number} [options.selectedIndex=0] - Onglet sélectionné
|
|
21
|
-
* @param {Function} [options.onChange] - Callback au changement
|
|
22
|
-
* @param {number} [options.height=48] - Hauteur
|
|
23
|
-
* @param {string} [options.indicatorColor] - Couleur indicateur (auto selon platform)
|
|
24
|
-
* @param {string} [options.textColor='#000000'] - Couleur texte
|
|
25
|
-
* @param {string} [options.selectedTextColor] - Couleur texte sélectionné (auto selon indicatorColor)
|
|
26
|
-
*/
|
|
27
9
|
constructor(framework, options = {}) {
|
|
28
10
|
super(framework, options);
|
|
29
|
-
this.tabs = options.tabs || [];
|
|
11
|
+
this.tabs = options.tabs || [];
|
|
30
12
|
this.selectedIndex = options.selectedIndex || 0;
|
|
31
13
|
this.onChange = options.onChange;
|
|
32
14
|
this.platform = framework.platform;
|
|
@@ -35,13 +17,159 @@ class Tabs extends Component {
|
|
|
35
17
|
this.textColor = options.textColor || '#000000';
|
|
36
18
|
this.selectedTextColor = options.selectedTextColor || this.indicatorColor;
|
|
37
19
|
|
|
20
|
+
// 🔹 Système de ripples amélioré
|
|
21
|
+
this.ripples = [];
|
|
22
|
+
this.animationFrame = null;
|
|
23
|
+
this.lastAnimationTime = 0;
|
|
24
|
+
|
|
25
|
+
// 🔹 Pour détecter les doubles événements
|
|
26
|
+
this.lastEventTime = 0;
|
|
27
|
+
this.lastEventCoords = { x: -1, y: -1 };
|
|
28
|
+
|
|
38
29
|
this.onPress = this.handlePress.bind(this);
|
|
39
30
|
}
|
|
40
31
|
|
|
41
32
|
/**
|
|
42
|
-
*
|
|
43
|
-
* @
|
|
33
|
+
* Démarre l'animation des ripples
|
|
34
|
+
* @private
|
|
44
35
|
*/
|
|
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
|
+
}
|
|
67
|
+
|
|
68
|
+
// Redessiner si nécessaire
|
|
69
|
+
if (needsUpdate) {
|
|
70
|
+
this.requestRender();
|
|
71
|
+
}
|
|
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;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (this.ripples.length > 0 && !this.animationFrame) {
|
|
83
|
+
this.animationFrame = requestAnimationFrame(animate);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Demander un redessin
|
|
89
|
+
* @private
|
|
90
|
+
*/
|
|
91
|
+
requestRender() {
|
|
92
|
+
if (this.framework && this.framework.requestRender) {
|
|
93
|
+
this.framework.requestRender();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Nettoyer l'animation lors de la destruction
|
|
99
|
+
*/
|
|
100
|
+
destroy() {
|
|
101
|
+
if (this.animationFrame) {
|
|
102
|
+
cancelAnimationFrame(this.animationFrame);
|
|
103
|
+
this.animationFrame = null;
|
|
104
|
+
}
|
|
105
|
+
if (super.destroy) {
|
|
106
|
+
super.destroy();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
handlePress(x, y) {
|
|
111
|
+
const now = performance.now();
|
|
112
|
+
|
|
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);
|
|
117
|
+
|
|
118
|
+
const isDoubleEvent = deltaX < 2 && deltaY < 2 && deltaTime < 50;
|
|
119
|
+
|
|
120
|
+
if (isDoubleEvent) {
|
|
121
|
+
return; // Ignorer le double événement
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 🔹 Enregistrer cet événement
|
|
125
|
+
this.lastEventTime = now;
|
|
126
|
+
this.lastEventCoords = { x, y };
|
|
127
|
+
|
|
128
|
+
const tabWidth = this.width / this.tabs.length;
|
|
129
|
+
const index = Math.floor((x - this.x) / tabWidth);
|
|
130
|
+
|
|
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
|
+
});
|
|
148
|
+
|
|
149
|
+
// Démarrer l'animation si elle n'est pas en cours
|
|
150
|
+
if (!this.animationFrame) {
|
|
151
|
+
this.startRippleAnimation();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Forcer un redessin
|
|
155
|
+
this.requestRender();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Changement d'onglet
|
|
159
|
+
if (index !== this.selectedIndex) {
|
|
160
|
+
this.selectedIndex = index;
|
|
161
|
+
if (this.onChange) this.onChange(index, this.tabs[index]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.requestRender();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
isPointInside(x, y) {
|
|
169
|
+
return x >= this.x && x <= this.x + this.width &&
|
|
170
|
+
y >= this.y && y <= this.y + this.height;
|
|
171
|
+
}
|
|
172
|
+
|
|
45
173
|
draw(ctx) {
|
|
46
174
|
ctx.save();
|
|
47
175
|
|
|
@@ -56,16 +184,22 @@ class Tabs extends Component {
|
|
|
56
184
|
ctx.moveTo(this.x, this.y + this.height);
|
|
57
185
|
ctx.lineTo(this.x + this.width, this.y + this.height);
|
|
58
186
|
ctx.stroke();
|
|
59
|
-
|
|
187
|
+
|
|
60
188
|
const tabWidth = this.width / this.tabs.length;
|
|
61
|
-
|
|
189
|
+
|
|
190
|
+
// 🔹 Dessiner les ripples Material EN PREMIER
|
|
191
|
+
if (this.platform === 'material') {
|
|
192
|
+
this.drawRipples(ctx, tabWidth);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Dessiner les onglets
|
|
62
196
|
for (let i = 0; i < this.tabs.length; i++) {
|
|
63
197
|
const tab = this.tabs[i];
|
|
64
198
|
const tabX = this.x + i * tabWidth;
|
|
65
199
|
const isSelected = i === this.selectedIndex;
|
|
66
200
|
const color = isSelected ? this.selectedTextColor : this.textColor;
|
|
67
|
-
|
|
68
|
-
// Icône (
|
|
201
|
+
|
|
202
|
+
// Icône (facultative)
|
|
69
203
|
if (tab.icon) {
|
|
70
204
|
ctx.font = '20px sans-serif';
|
|
71
205
|
ctx.textAlign = 'center';
|
|
@@ -73,7 +207,7 @@ class Tabs extends Component {
|
|
|
73
207
|
ctx.fillStyle = color;
|
|
74
208
|
ctx.fillText(tab.icon, tabX + tabWidth / 2, this.y + 16);
|
|
75
209
|
}
|
|
76
|
-
|
|
210
|
+
|
|
77
211
|
// Label
|
|
78
212
|
ctx.font = `${isSelected ? 'bold ' : ''}14px -apple-system, Roboto, sans-serif`;
|
|
79
213
|
ctx.fillStyle = color;
|
|
@@ -81,44 +215,40 @@ class Tabs extends Component {
|
|
|
81
215
|
ctx.textBaseline = 'middle';
|
|
82
216
|
const labelY = tab.icon ? this.y + 36 : this.y + this.height / 2;
|
|
83
217
|
ctx.fillText(tab.label, tabX + tabWidth / 2, labelY);
|
|
84
|
-
|
|
85
|
-
// Indicateur (Material
|
|
218
|
+
|
|
219
|
+
// Indicateur (Material)
|
|
86
220
|
if (isSelected && this.platform === 'material') {
|
|
87
221
|
ctx.fillStyle = this.indicatorColor;
|
|
88
222
|
ctx.fillRect(tabX, this.y + this.height - 2, tabWidth, 2);
|
|
89
223
|
}
|
|
90
224
|
}
|
|
91
|
-
|
|
225
|
+
|
|
92
226
|
ctx.restore();
|
|
93
227
|
}
|
|
94
228
|
|
|
95
229
|
/**
|
|
96
|
-
*
|
|
97
|
-
* @param {number} x - Coordonnée X
|
|
98
|
-
* @param {number} y - Coordonnée Y
|
|
230
|
+
* Dessine les ripples (Material)
|
|
99
231
|
* @private
|
|
100
232
|
*/
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
233
|
+
drawRipples(ctx, tabWidth) {
|
|
234
|
+
// Sauvegarder le contexte
|
|
235
|
+
ctx.save();
|
|
104
236
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
237
|
+
// Créer un masque de clipping pour limiter les ripples aux onglets
|
|
238
|
+
ctx.beginPath();
|
|
239
|
+
ctx.rect(this.x, this.y, this.width, this.height);
|
|
240
|
+
ctx.clip();
|
|
241
|
+
|
|
242
|
+
for (let ripple of this.ripples) {
|
|
243
|
+
ctx.globalAlpha = ripple.opacity;
|
|
244
|
+
ctx.fillStyle = this.indicatorColor || '#6200EE';
|
|
245
|
+
ctx.beginPath();
|
|
246
|
+
ctx.arc(ripple.x, ripple.y, ripple.radius, 0, Math.PI * 2);
|
|
247
|
+
ctx.fill();
|
|
110
248
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
* Vérifie si un point est dans les limites
|
|
115
|
-
* @param {number} x - Coordonnée X
|
|
116
|
-
* @param {number} y - Coordonnée Y
|
|
117
|
-
* @returns {boolean} True si le point est dans les onglets
|
|
118
|
-
*/
|
|
119
|
-
isPointInside(x, y) {
|
|
120
|
-
return x >= this.x && x <= this.x + this.width &&
|
|
121
|
-
y >= this.y && y <= this.y + this.height;
|
|
249
|
+
|
|
250
|
+
// Restaurer le contexte
|
|
251
|
+
ctx.restore();
|
|
122
252
|
}
|
|
123
253
|
}
|
|
124
254
|
|