canvasframework 0.3.9 → 0.3.11

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,586 @@
1
+ import Component from '../core/Component.js';
2
+
3
+ /**
4
+ * Champ de saisie de tags avec gestion de tags multiples
5
+ * @class
6
+ * @extends Component
7
+ * @property {string} placeholder - Texte d'indication
8
+ * @property {string} value - Valeur en cours de saisie
9
+ * @property {Array} tags - Liste des tags
10
+ * @property {number} fontSize - Taille de police
11
+ * @property {boolean} focused - Focus actif
12
+ * @property {string} platform - Plateforme
13
+ * @property {boolean} cursorVisible - Curseur visible
14
+ * @property {number} cursorPosition - Position du curseur
15
+ * @property {HTMLInputElement} hiddenInput - Input HTML caché
16
+ * @property {number} tagPadding - Padding interne des tags
17
+ * @property {number} tagSpacing - Espacement entre les tags
18
+ * @property {string} tagColor - Couleur des tags
19
+ * @property {string} tagTextColor - Couleur du texte des tags
20
+ * @property {string} deleteButtonColor - Couleur du bouton de suppression
21
+ */
22
+ class InputTags extends Component {
23
+ static activeInput = null;
24
+ static allInputs = new Set();
25
+ static globalClickHandler = null;
26
+
27
+ /**
28
+ * Crée une instance de InputTags
29
+ * @param {CanvasFramework} framework - Framework parent
30
+ * @param {Object} [options={}] - Options de configuration
31
+ * @param {string} [options.placeholder=''] - Texte d'indication
32
+ * @param {Array} [options.tags=[]] - Tags initiaux
33
+ * @param {string} [options.value=''] - Valeur initiale
34
+ * @param {number} [options.fontSize=16] - Taille de police
35
+ * @param {Function} [options.onFocus] - Callback au focus
36
+ * @param {Function} [options.onBlur] - Callback au blur
37
+ * @param {Function} [options.onTagAdd] - Callback quand un tag est ajouté
38
+ * @param {Function} [options.onTagRemove] - Callback quand un tag est supprimé
39
+ * @param {number} [options.tagPadding=8] - Padding interne des tags
40
+ * @param {number} [options.tagSpacing=6] - Espacement entre les tags
41
+ * @param {string} [options.tagColor='#E0E0E0'] - Couleur des tags
42
+ * @param {string} [options.tagTextColor='#333333'] - Couleur du texte des tags
43
+ * @param {string} [options.deleteButtonColor='#666666'] - Couleur du bouton de suppression
44
+ */
45
+ constructor(framework, options = {}) {
46
+ super(framework, options);
47
+ this.placeholder = options.placeholder || 'Ajouter des tags...';
48
+ this.value = options.value || '';
49
+ this.tags = Array.isArray(options.tags) ? [...options.tags] : [];
50
+ this.fontSize = options.fontSize || 16;
51
+ this.focused = false;
52
+ this.platform = framework.platform;
53
+ this.cursorVisible = true;
54
+ this.cursorPosition = this.value.length;
55
+
56
+ // Configuration des tags
57
+ this.tagPadding = options.tagPadding || 8;
58
+ this.tagSpacing = options.tagSpacing || 6;
59
+ this.tagColor = options.tagColor || '#E0E0E0';
60
+ this.tagTextColor = options.tagTextColor || '#333333';
61
+ this.deleteButtonColor = options.deleteButtonColor || '#666666';
62
+
63
+ // Callbacks
64
+ this.onTagAdd = options.onTagAdd || (() => {});
65
+ this.onTagRemove = options.onTagRemove || (() => {});
66
+
67
+ // Calculs de layout
68
+ this.tagHeight = this.fontSize + this.tagPadding * 2;
69
+ this.deleteButtonSize = this.fontSize * 0.8;
70
+
71
+ // Gestion du focus
72
+ this.onFocus = this.onFocus.bind(this);
73
+ this.onBlur = this.onBlur.bind(this);
74
+
75
+ // Enregistrer cet input
76
+ InputTags.allInputs.add(this);
77
+
78
+ // Animation du curseur
79
+ this.cursorInterval = setInterval(() => {
80
+ if (this.focused) this.cursorVisible = !this.cursorVisible;
81
+ }, 500);
82
+
83
+ // Écouter les clics partout pour détecter quand on clique ailleurs
84
+ this.setupGlobalClickHandler();
85
+ }
86
+
87
+ /**
88
+ * Écoute les clics globaux pour détecter les clics hors input
89
+ */
90
+ setupGlobalClickHandler() {
91
+ if (!InputTags.globalClickHandler) {
92
+ InputTags.globalClickHandler = (e) => {
93
+ let clickedOnInput = false;
94
+
95
+ for (let input of InputTags.allInputs) {
96
+ if (input.hiddenInput && e.target === input.hiddenInput) {
97
+ clickedOnInput = true;
98
+ break;
99
+ }
100
+ }
101
+
102
+ if (!clickedOnInput) {
103
+ InputTags.removeAllHiddenInputs();
104
+ }
105
+ };
106
+
107
+ document.addEventListener('click', InputTags.globalClickHandler, true);
108
+ document.addEventListener('touchstart', InputTags.globalClickHandler, true);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Configure l'input HTML caché
114
+ * @private
115
+ */
116
+ setupHiddenInput() {
117
+ if (this.hiddenInput) return;
118
+
119
+ this.hiddenInput = document.createElement('input');
120
+ this.hiddenInput.style.position = 'fixed';
121
+ this.hiddenInput.style.opacity = '0';
122
+ this.hiddenInput.style.pointerEvents = 'none';
123
+ this.hiddenInput.style.top = '-100px';
124
+ this.hiddenInput.style.zIndex = '9999';
125
+ document.body.appendChild(this.hiddenInput);
126
+
127
+ this.hiddenInput.addEventListener('input', (e) => {
128
+ if (this.focused) {
129
+ this.value = e.target.value;
130
+ this.cursorPosition = this.value.length;
131
+ }
132
+ });
133
+
134
+ this.hiddenInput.addEventListener('keydown', (e) => {
135
+ if (e.key === 'Enter' || e.key === ',') {
136
+ e.preventDefault();
137
+ this.addCurrentTag();
138
+ } else if (e.key === 'Backspace' && this.value === '' && this.tags.length > 0) {
139
+ this.removeLastTag();
140
+ }
141
+ });
142
+
143
+ this.hiddenInput.addEventListener('blur', () => {
144
+ // Ajouter le tag en cours si non vide
145
+ if (this.value.trim() !== '') {
146
+ this.addCurrentTag();
147
+ }
148
+
149
+ this.focused = false;
150
+ this.cursorVisible = false;
151
+
152
+ setTimeout(() => {
153
+ this.destroyHiddenInput();
154
+ }, 100);
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Ajoute le tag en cours de saisie
160
+ */
161
+ addCurrentTag() {
162
+ const tag = this.value.trim();
163
+ if (tag !== '' && !this.tags.includes(tag)) {
164
+ this.tags.push(tag);
165
+ this.value = '';
166
+ this.cursorPosition = 0;
167
+
168
+ if (this.hiddenInput) {
169
+ this.hiddenInput.value = '';
170
+ }
171
+
172
+ this.onTagAdd(tag, this.tags);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Ajoute un tag spécifique
178
+ * @param {string} tag - Tag à ajouter
179
+ */
180
+ addTag(tag) {
181
+ const trimmedTag = tag.trim();
182
+ if (trimmedTag !== '' && !this.tags.includes(trimmedTag)) {
183
+ this.tags.push(trimmedTag);
184
+ this.onTagAdd(trimmedTag, this.tags);
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Supprime un tag par son index
190
+ * @param {number} index - Index du tag à supprimer
191
+ */
192
+ removeTag(index) {
193
+ if (index >= 0 && index < this.tags.length) {
194
+ const removedTag = this.tags[index];
195
+ this.tags.splice(index, 1);
196
+ this.onTagRemove(removedTag, this.tags);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Supprime le dernier tag
202
+ */
203
+ removeLastTag() {
204
+ if (this.tags.length > 0) {
205
+ this.removeTag(this.tags.length - 1);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Supprime tous les tags
211
+ */
212
+ clearTags() {
213
+ const oldTags = [...this.tags];
214
+ this.tags = [];
215
+ oldTags.forEach(tag => this.onTagRemove(tag, this.tags));
216
+ }
217
+
218
+ /**
219
+ * Vérifie si un point est sur le bouton de suppression d'un tag
220
+ * @param {number} x - Coordonnée X
221
+ * @param {number} y - Coordonnée Y
222
+ * @returns {number|null} Index du tag ou null
223
+ */
224
+ getTagIndexAtPoint(x, y) {
225
+ let currentX = this.x + 10;
226
+ const tagY = this.y + 10;
227
+
228
+ for (let i = 0; i < this.tags.length; i++) {
229
+ const tag = this.tags[i];
230
+ const tagWidth = this.measureTagWidth(tag);
231
+
232
+ // Vérifier si le point est dans le tag
233
+ if (x >= currentX && x <= currentX + tagWidth &&
234
+ y >= tagY && y <= tagY + this.tagHeight) {
235
+ return i;
236
+ }
237
+
238
+ currentX += tagWidth + this.tagSpacing;
239
+ }
240
+
241
+ return null;
242
+ }
243
+
244
+ /**
245
+ * Vérifie si un point est sur le bouton de suppression d'un tag
246
+ * @param {number} x - Coordonnée X
247
+ * @param {number} y - Coordonnée Y
248
+ * @returns {number|null} Index du tag ou null
249
+ */
250
+ getDeleteButtonIndexAtPoint(x, y) {
251
+ let currentX = this.x + 10;
252
+ const tagY = this.y + 10;
253
+
254
+ for (let i = 0; i < this.tags.length; i++) {
255
+ const tag = this.tags[i];
256
+ const tagWidth = this.measureTagWidth(tag);
257
+ const deleteButtonX = currentX + tagWidth - this.deleteButtonSize - this.tagPadding / 2;
258
+ const deleteButtonY = tagY + (this.tagHeight - this.deleteButtonSize) / 2;
259
+
260
+ // Vérifier si le point est sur le bouton de suppression
261
+ if (x >= deleteButtonX && x <= deleteButtonX + this.deleteButtonSize &&
262
+ y >= deleteButtonY && y <= deleteButtonY + this.deleteButtonSize) {
263
+ return i;
264
+ }
265
+
266
+ currentX += tagWidth + this.tagSpacing;
267
+ }
268
+
269
+ return null;
270
+ }
271
+
272
+ /**
273
+ * Mesure la largeur d'un tag
274
+ * @param {string} tag - Tag à mesurer
275
+ * @returns {number} Largeur du tag
276
+ */
277
+ measureTagWidth(tag) {
278
+ // Approximation de la largeur du texte
279
+ const textWidth = tag.length * (this.fontSize * 0.6);
280
+ return textWidth + this.tagPadding * 2 + this.deleteButtonSize + this.tagPadding / 2;
281
+ }
282
+
283
+ /**
284
+ * Gère le focus
285
+ */
286
+ onFocus() {
287
+ if (InputTags.activeInput === this) {
288
+ return;
289
+ }
290
+
291
+ InputTags.removeAllHiddenInputs();
292
+
293
+ for (let input of InputTags.allInputs) {
294
+ if (input !== this) {
295
+ input.focused = false;
296
+ input.cursorVisible = false;
297
+ }
298
+ }
299
+
300
+ this.focused = true;
301
+ this.cursorVisible = true;
302
+ InputTags.activeInput = this;
303
+
304
+ this.setupHiddenInput();
305
+
306
+ if (this.hiddenInput) {
307
+ this.hiddenInput.value = this.value;
308
+
309
+ const adjustedY = this.y + this.framework.scrollOffset;
310
+ this.hiddenInput.style.top = `${adjustedY}px`;
311
+
312
+ setTimeout(() => {
313
+ if (this.hiddenInput && this.focused) {
314
+ this.hiddenInput.focus();
315
+ this.hiddenInput.setSelectionRange(this.value.length, this.value.length);
316
+ }
317
+ }, 50);
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Gère le blur
323
+ */
324
+ onBlur() {
325
+ this.focused = false;
326
+ this.cursorVisible = false;
327
+ }
328
+
329
+ /**
330
+ * Détruit l'input HTML
331
+ */
332
+ destroyHiddenInput() {
333
+ if (this.hiddenInput && this.hiddenInput.parentNode) {
334
+ this.hiddenInput.parentNode.removeChild(this.hiddenInput);
335
+ this.hiddenInput = null;
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Gère le clic
341
+ * @param {number} x - Coordonnée X du clic
342
+ * @param {number} y - Coordonnée Y du clic
343
+ * @returns {boolean} True si le clic a été géré
344
+ */
345
+ onClick(x, y) {
346
+ // Vérifier si on clique sur un bouton de suppression
347
+ const deleteIndex = this.getDeleteButtonIndexAtPoint(x, y);
348
+ if (deleteIndex !== null) {
349
+ this.removeTag(deleteIndex);
350
+ return true;
351
+ }
352
+
353
+ // Vérifier si on clique sur un tag (pour focus l'input)
354
+ const tagIndex = this.getTagIndexAtPoint(x, y);
355
+ if (tagIndex !== null) {
356
+ this.onFocus();
357
+ return true;
358
+ }
359
+
360
+ // Vérifier si on clique dans la zone d'input
361
+ if (this.isPointInside(x, y)) {
362
+ this.onFocus();
363
+ return true;
364
+ }
365
+
366
+ return false;
367
+ }
368
+
369
+ /**
370
+ * Méthode statique pour détruire tous les inputs HTML
371
+ */
372
+ static removeAllHiddenInputs() {
373
+ for (let input of InputTags.allInputs) {
374
+ input.focused = false;
375
+ input.cursorVisible = false;
376
+
377
+ if (input.hiddenInput && input.hiddenInput.parentNode) {
378
+ input.hiddenInput.parentNode.removeChild(input.hiddenInput);
379
+ input.hiddenInput = null;
380
+ }
381
+ }
382
+
383
+ InputTags.activeInput = null;
384
+ }
385
+
386
+ /**
387
+ * Vérifie si un point est dans les limites
388
+ * @param {number} x - Coordonnée X
389
+ * @param {number} y - Coordonnée Y
390
+ * @returns {boolean} True si le point est dans l'input
391
+ */
392
+ isPointInside(x, y) {
393
+ return x >= this.x &&
394
+ x <= this.x + this.width &&
395
+ y >= this.y &&
396
+ y <= this.y + this.height;
397
+ }
398
+
399
+ /**
400
+ * Calcule la position X du curseur
401
+ * @returns {number} Position X du curseur
402
+ */
403
+ getCursorXPosition() {
404
+ let cursorX = this.x + 10;
405
+
406
+ // Ajouter la largeur de tous les tags
407
+ for (let tag of this.tags) {
408
+ cursorX += this.measureTagWidth(tag) + this.tagSpacing;
409
+ }
410
+
411
+ // Ajouter la largeur du texte en cours
412
+ const textWidth = this.value.length * (this.fontSize * 0.6);
413
+ cursorX += textWidth;
414
+
415
+ return cursorX;
416
+ }
417
+
418
+ /**
419
+ * Dessine l'input
420
+ * @param {CanvasRenderingContext2D} ctx - Contexte de dessin
421
+ */
422
+ draw(ctx) {
423
+ ctx.save();
424
+
425
+ if (this.platform === 'material') {
426
+ ctx.strokeStyle = this.focused ? '#6200EE' : '#CCCCCC';
427
+ ctx.lineWidth = this.focused ? 2 : 1;
428
+ ctx.beginPath();
429
+ ctx.moveTo(this.x, this.y + this.height);
430
+ ctx.lineTo(this.x + this.width, this.y + this.height);
431
+ ctx.stroke();
432
+ } else {
433
+ ctx.strokeStyle = this.focused ? '#007AFF' : '#C7C7CC';
434
+ ctx.lineWidth = 1;
435
+ ctx.beginPath();
436
+ this.roundRect(ctx, this.x, this.y, this.width, this.height, 8);
437
+ ctx.stroke();
438
+ }
439
+
440
+ // Position de départ pour dessiner les tags
441
+ let currentX = this.x + 10;
442
+ const tagY = this.y + 10;
443
+
444
+ // Dessiner les tags
445
+ for (let i = 0; i < this.tags.length; i++) {
446
+ this.drawTag(ctx, this.tags[i], currentX, tagY);
447
+ const tagWidth = this.measureTagWidth(this.tags[i]);
448
+ currentX += tagWidth + this.tagSpacing;
449
+ }
450
+
451
+ // Dessiner le texte en cours
452
+ if (this.value || (this.focused && this.tags.length === 0)) {
453
+ ctx.fillStyle = this.value ? '#000000' : '#999999';
454
+ ctx.font = `${this.fontSize}px -apple-system, BlinkMacSystemFont, 'Roboto', sans-serif`;
455
+ ctx.textAlign = 'left';
456
+ ctx.textBaseline = 'middle';
457
+
458
+ const displayText = this.value || this.placeholder;
459
+ const textY = this.y + this.height / 2;
460
+
461
+ ctx.fillText(displayText, currentX, textY);
462
+
463
+ // Curseur
464
+ if (this.focused && this.cursorVisible) {
465
+ const textWidth = ctx.measureText(this.value).width;
466
+ ctx.fillStyle = '#000000';
467
+ ctx.fillRect(currentX + textWidth, textY - this.fontSize / 2, 2, this.fontSize);
468
+ }
469
+ }
470
+
471
+ ctx.restore();
472
+ }
473
+
474
+ /**
475
+ * Dessine un tag
476
+ * @param {CanvasRenderingContext2D} ctx - Contexte de dessin
477
+ * @param {string} tag - Texte du tag
478
+ * @param {number} x - Position X
479
+ * @param {number} y - Position Y
480
+ */
481
+ drawTag(ctx, tag, x, y) {
482
+ const tagWidth = this.measureTagWidth(tag);
483
+
484
+ // Fond du tag
485
+ ctx.fillStyle = this.tagColor;
486
+ this.roundRect(ctx, x, y, tagWidth, this.tagHeight, this.tagHeight / 2);
487
+ ctx.fill();
488
+
489
+ // Texte du tag
490
+ ctx.fillStyle = this.tagTextColor;
491
+ ctx.font = `${this.fontSize}px -apple-system, BlinkMacSystemFont, 'Roboto', sans-serif`;
492
+ ctx.textAlign = 'left';
493
+ ctx.textBaseline = 'middle';
494
+
495
+ const textX = x + this.tagPadding;
496
+ const textY = y + this.tagHeight / 2;
497
+
498
+ // Tronquer le texte si trop long
499
+ const maxTextWidth = tagWidth - this.tagPadding * 2 - this.deleteButtonSize - this.tagPadding / 2;
500
+ let displayTag = tag;
501
+ let textWidth = ctx.measureText(tag).width;
502
+
503
+ if (textWidth > maxTextWidth) {
504
+ // Tronquer le texte avec "..."
505
+ for (let i = tag.length; i > 0; i--) {
506
+ const truncated = tag.substring(0, i) + '...';
507
+ if (ctx.measureText(truncated).width <= maxTextWidth) {
508
+ displayTag = truncated;
509
+ break;
510
+ }
511
+ }
512
+ }
513
+
514
+ ctx.fillText(displayTag, textX, textY);
515
+
516
+ // Bouton de suppression (×)
517
+ const deleteButtonX = x + tagWidth - this.deleteButtonSize - this.tagPadding / 2;
518
+ const deleteButtonY = y + (this.tagHeight - this.deleteButtonSize) / 2;
519
+
520
+ ctx.strokeStyle = this.deleteButtonColor;
521
+ ctx.lineWidth = 2;
522
+ ctx.beginPath();
523
+
524
+ // Croix
525
+ const centerX = deleteButtonX + this.deleteButtonSize / 2;
526
+ const centerY = deleteButtonY + this.deleteButtonSize / 2;
527
+ const crossSize = this.deleteButtonSize / 3;
528
+
529
+ ctx.moveTo(centerX - crossSize, centerY - crossSize);
530
+ ctx.lineTo(centerX + crossSize, centerY + crossSize);
531
+ ctx.moveTo(centerX + crossSize, centerY - crossSize);
532
+ ctx.lineTo(centerX - crossSize, centerY + crossSize);
533
+ ctx.stroke();
534
+ }
535
+
536
+ /**
537
+ * Dessine un rectangle avec coins arrondis
538
+ * @param {CanvasRenderingContext2D} ctx - Contexte de dessin
539
+ * @param {number} x - Position X
540
+ * @param {number} y - Position Y
541
+ * @param {number} width - Largeur
542
+ * @param {number} height - Hauteur
543
+ * @param {number} radius - Rayon des coins
544
+ * @private
545
+ */
546
+ roundRect(ctx, x, y, width, height, radius) {
547
+ if (ctx.roundRect) {
548
+ ctx.roundRect(x, y, width, height, radius);
549
+ } else {
550
+ ctx.beginPath();
551
+ ctx.moveTo(x + radius, y);
552
+ ctx.lineTo(x + width - radius, y);
553
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
554
+ ctx.lineTo(x + width, y + height - radius);
555
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
556
+ ctx.lineTo(x + radius, y + height);
557
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
558
+ ctx.lineTo(x, y + radius);
559
+ ctx.quadraticCurveTo(x, y, x + radius, y);
560
+ ctx.closePath();
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Nettoie les ressources
566
+ */
567
+ destroy() {
568
+ this.destroyHiddenInput();
569
+
570
+ if (this.cursorInterval) {
571
+ clearInterval(this.cursorInterval);
572
+ }
573
+
574
+ InputTags.allInputs.delete(this);
575
+
576
+ if (InputTags.allInputs.size === 0 && InputTags.globalClickHandler) {
577
+ document.removeEventListener('click', InputTags.globalClickHandler, true);
578
+ document.removeEventListener('touchstart', InputTags.globalClickHandler, true);
579
+ InputTags.globalClickHandler = null;
580
+ }
581
+
582
+ super.destroy && super.destroy();
583
+ }
584
+ }
585
+
586
+ export default InputTags;