canvasframework 0.4.7 → 0.4.9

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.
@@ -103,27 +103,29 @@ import FeatureFlags from '../manager/FeatureFlags.js';
103
103
 
104
104
  // WebGL Adapter
105
105
  import WebGLCanvasAdapter from './WebGLCanvasAdapter.js';
106
- import ui, { createRef } from './UIBuilder.js';
106
+ import ui, {
107
+ createRef
108
+ } from './UIBuilder.js';
107
109
  import ThemeManager from './ThemeManager.js';
108
110
 
109
111
 
110
112
  const FIXED_COMPONENT_TYPES = new Set([
111
- AppBar,
112
- BottomNavigationBar,
113
- Drawer,
114
- Dialog,
115
- Modal,
116
- Tabs,
117
- FAB,
118
- Toast,
119
- Camera,
120
- QRCodeReader,
121
- Banner,
122
- SliverAppBar,
123
- BottomSheet,
124
- ContextMenu,
125
- OpenStreetMap,
126
- SelectDialog
113
+ AppBar,
114
+ BottomNavigationBar,
115
+ Drawer,
116
+ Dialog,
117
+ Modal,
118
+ Tabs,
119
+ FAB,
120
+ Toast,
121
+ Camera,
122
+ QRCodeReader,
123
+ Banner,
124
+ SliverAppBar,
125
+ BottomSheet,
126
+ ContextMenu,
127
+ OpenStreetMap,
128
+ SelectDialog
127
129
  ]);
128
130
 
129
131
  /**
@@ -146,290 +148,513 @@ const FIXED_COMPONENT_TYPES = new Set([
146
148
  * @property {number} scrollFriction - Friction du défilement
147
149
  */
148
150
  class CanvasFramework {
149
- /**
150
- * Crée une instance de CanvasFramework
151
- * @param {string} canvasId - ID de l'élément canvas
152
- */
153
- constructor(canvasId, options = {}) {
154
- this.canvas = document.getElementById(canvasId);
155
- this.ctx = this.canvas.getContext('2d');
156
- this.width = window.innerWidth;
157
- this.height = window.innerHeight;
158
- this.dpr = window.devicePixelRatio || 1;
159
-
160
- this.platform = this.detectPlatform();
161
-
162
-
163
- // État actuel + préférence
164
- this.themeMode = options.themeMode || 'system'; // 'light', 'dark', 'system'
165
- this.userThemeOverride = null; // null = suit system, sinon 'light' ou 'dark'
166
-
167
- // Applique le thème initial
168
- this.setupSystemThemeListener();
169
-
170
- // Récupère override utilisateur
171
- const savedOverride = localStorage.getItem('themeOverride');
172
- if (savedOverride && ['light', 'dark'].includes(savedOverride)) {
173
- this.userThemeOverride = savedOverride;
174
- this.themeMode = savedOverride;
175
- }
176
-
177
- this.components = [];
178
- // ✅ AJOUTER ICI :
179
- this._cachedMaxScroll = 0;
180
- this._maxScrollDirty = true;
181
- this.resizeTimeout = null;
182
-
183
- //this.applyThemeFromSystem();
184
- this.state = {};
185
- // NOUVELLE OPTION: choisir entre Canvas 2D et WebGL
186
- this.useWebGL = options.useWebGL !== false; // true par défaut
187
- // Initialiser le contexte approprié
188
- if (this.useWebGL) {
189
- try {
190
- this.ctx = new WebGLCanvasAdapter(this.canvas);
191
- } catch (e) {
151
+ /**
152
+ * Crée une instance de CanvasFramework
153
+ * @param {string} canvasId - ID de l'élément canvas
154
+ */
155
+ constructor(canvasId, options = {}) {
156
+ // AJOUTER: Démarrer le chronomètre
157
+ const startTime = performance.now();
158
+ this.canvas = document.getElementById(canvasId);
192
159
  this.ctx = this.canvas.getContext('2d');
193
- this.useWebGL = false;
194
- }
195
- } else {
196
- this.ctx = this.canvas.getContext('2d');
197
- }
198
- // Calcule FPS
199
- this.fps = 0;
200
- this._frames = 0;
201
- this._lastFpsTime = performance.now();
202
- this.showFps = options.showFps || false; // false par défaut
203
- this.debbug = options.debug || false; // false par défaut (et correction de la faute de frappe)
204
-
205
- // Worker pour multithreading Canvas Worker
206
- this.worker = this.createCanvasWorker();
207
- this.worker.onmessage = this.handleWorkerMessage.bind(this);
208
- this.worker.postMessage({ type: 'INIT', payload: { components: [] } });
209
-
210
- // Logic Worker
211
- this.logicWorker = this.createLogicWorker();
212
- this.logicWorker.onmessage = this.handleLogicWorkerMessage.bind(this);
213
- this.logicWorkerState = {};
214
- this.logicWorker.postMessage({ type: 'SET_STATE', payload: this.state });
215
-
216
- // Envoyer l'état initial au worker
217
- this.logicWorker.postMessage({ type: 'SET_STATE', payload: this.state });
218
-
219
- // Gestion des événements
220
- this.isDragging = false;
221
- this.lastTouchY = 0;
222
- this.scrollOffset = 0;
223
- this.scrollVelocity = 0;
224
- this.scrollFriction = 0.95;
225
-
226
- // Optimisation
227
- this.dirtyComponents = new Set();
228
- this.optimizationEnabled = false;
229
-
230
- // AJOUTER CETTE LIGNE
231
- this.animator = new AnimationEngine();
232
-
233
- // ===== NOUVEAU SYSTÈME DE ROUTING =====
234
- this.routes = new Map();
235
- this.currentRoute = '/';
236
- this.currentParams = {};
237
- this.currentQuery = {};
238
- this.history = [];
239
- this.historyIndex = -1;
240
-
241
- // Animation de transition
242
- this.transitionState = {
243
- isTransitioning: false,
244
- progress: 0,
245
- duration: 300,
246
- type: 'slide', // 'slide', 'fade', 'none'
247
- direction: 'forward', // 'forward', 'back'
248
- oldComponents: [],
249
- newComponents: []
250
- };
251
-
252
- this.setupCanvas();
253
- this.setupEventListeners();
254
- this.setupHistoryListener();
255
- this.startRenderLoop();
256
-
257
- this.devTools = new DevTools(this);
258
- this.inspectionOverlay = new InspectionOverlay(this);
259
-
260
- // MODIFICATION: Vérifier explicitement l'option enableDevTools
261
- const shouldEnableDevTools = options.enableDevTools === true;
262
-
263
- if (shouldEnableDevTools) {
264
- this.enableDevTools();
265
- console.log('DevTools enabled. Press Ctrl+Shift+D to toggle.');
266
- }
267
-
268
- // Initialiser le ThemeManager
269
- this.themeManager = new ThemeManager(this, {
270
- lightTheme: options.lightTheme,
271
- darkTheme: options.darkTheme,
272
- storageKey: options.themeStorageKey || 'app-theme-mode'
273
- });
274
-
275
- // Raccourci pour accéder au thème actuel
276
- this.theme = this.themeManager.getTheme();
277
- }
278
-
279
- /**
280
- * Écoute les changements système (ex: utilisateur bascule dark mode)
281
- */
282
- setupSystemThemeListener() {
283
- const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
284
-
285
- // Ancienne méthode (compatibilité large)
286
- if (mediaQuery.addEventListener) {
287
- mediaQuery.addEventListener('change', (e) => {
288
- if (this.themeMode === 'system') {
289
- this.applyThemeFromSystem();
160
+ this.width = window.innerWidth;
161
+ this.height = window.innerHeight;
162
+ this.dpr = window.devicePixelRatio || 1;
163
+ this.splashOptions = {
164
+ enabled: options.splash?.enabled === true, // false par défaut
165
+ duration: options.splash?.duration || 700,
166
+ fadeOutDuration: options.splash?.fadeOutDuration || 500,
167
+ backgroundColor: options.splash?.backgroundColor || ['#667eea', '#764ba2'], // Gradient ou couleur unie
168
+ spinnerColor: options.splash?.spinnerColor || 'white',
169
+ spinnerBackground: options.splash?.spinnerBackground || 'rgba(255, 255, 255, 0.3)',
170
+ textColor: options.splash?.textColor || 'white',
171
+ text: options.splash?.text || 'Chargement...',
172
+ textSize: options.splash?.textSize || 20,
173
+ textFont: options.splash?.textFont || 'Arial',
174
+ progressBarColor: options.splash?.progressBarColor || 'white',
175
+ progressBarBackground: options.splash?.progressBarBackground || 'rgba(255, 255, 255, 0.3)',
176
+ showProgressBar: options.splash?.showProgressBar !== false, // true par défaut
177
+ logo: options.splash?.logo || null, // URL d'une image (optionnel)
178
+ logoWidth: options.splash?.logoWidth || 100,
179
+ logoHeight: options.splash?.logoHeight || 100
180
+ };
181
+ // MODIFIER : Vérifier si le splash est activé
182
+ if (this.splashOptions.enabled) {
183
+ this.showSplashScreen();
184
+ } else {
185
+ this._splashFinished = true; // Passer directement au rendu
290
186
  }
291
- });
292
- } else {
293
- // Anciens navigateurs (rare en 2026)
294
- mediaQuery.addListener((e) => {
295
- if (this.themeMode === 'system') {
296
- this.applyThemeFromSystem();
187
+
188
+ this.platform = this.detectPlatform();
189
+
190
+
191
+ // État actuel + préférence
192
+ this.themeMode = options.themeMode || 'system'; // 'light', 'dark', 'system'
193
+ this.userThemeOverride = null; // null = suit system, sinon 'light' ou 'dark'
194
+
195
+ // Applique le thème initial
196
+ this.setupSystemThemeListener();
197
+
198
+ // Récupère override utilisateur
199
+ const savedOverride = localStorage.getItem('themeOverride');
200
+ if (savedOverride && ['light', 'dark'].includes(savedOverride)) {
201
+ this.userThemeOverride = savedOverride;
202
+ this.themeMode = savedOverride;
297
203
  }
298
- });
299
- }
300
- }
301
-
302
- /**
303
- * Change le mode thème
304
- * @param {'light'|'dark'|'system'} mode - Mode à appliquer
305
- * @param {boolean} [save=true] - Sauvegarder le choix utilisateur ?
306
- */
307
- setThemeMode(mode) {
308
- this.themeManager.setMode(mode);
309
- this.theme = this.themeManager.getTheme();
310
- }
311
-
312
- /**
313
- * Obtient une couleur du thème
314
- */
315
- getColor(colorName) {
316
- return this.themeManager.getColor(colorName);
317
- }
318
-
319
- /**
320
- * Ajoute un listener de changement de thème
321
- */
322
- onThemeChange(callback) {
323
- this.themeManager.addListener((theme) => {
324
- this.theme = theme;
325
- callback(theme);
326
- });
327
- }
328
-
329
- /**
330
- * Bascule entre light et dark
331
- */
332
- toggleTheme() {
333
- this.themeManager.toggle();
334
- this.theme = this.themeManager.getTheme();
335
- }
336
-
337
- /**
338
- * Active ou désactive les DevTools
339
- * @param {boolean} enabled - true pour activer, false pour désactiver
340
- */
341
- enableDevTools(enabled = true) {
342
- if (enabled) {
343
- // Créer le DevTools s'il n'existe pas
344
- if (!this.devTools) {
345
- this.devTools = new DevTools(this);
346
- }
347
-
348
- // Attacher seulement si pas déjà fait
349
- if (!this.devTools._isAttached) {
350
- this.devTools.attachToFramework();
351
- this.devTools._isAttached = true;
352
- }
353
-
354
- // Afficher le bouton
355
- if (this.devTools.toggleBtn) {
356
- this.devTools.toggleBtn.style.display = 'block';
357
- }
358
- } else {
359
- // Désactiver complètement
360
- if (this.devTools) {
361
- // Détacher du framework
362
- if (this.devTools.detachFromFramework) {
363
- this.devTools.detachFromFramework();
364
- } else if (this.devTools.cleanup) {
365
- this.devTools.cleanup();
366
- }
367
-
368
- // Supprimer de la page DOM
369
- if (this.devTools.container && this.devTools.container.parentNode) {
370
- this.devTools.container.parentNode.removeChild(this.devTools.container);
371
- }
372
-
373
- if (this.devTools.toggleBtn && this.devTools.toggleBtn.parentNode) {
374
- this.devTools.toggleBtn.parentNode.removeChild(this.devTools.toggleBtn);
375
- }
376
-
377
- this.devTools._isAttached = false;
378
- }
379
- }
380
- }
381
-
382
- /**
383
- * Bascule l'overlay d'inspection
384
- */
385
- toggleInspection() {
386
- this.inspectionOverlay.toggle();
387
- }
388
-
389
- /**
390
- * Exécute une commande DevTools
391
- */
392
- devToolsCommand(command, ...args) {
393
- switch (command) {
394
- case 'inspect':
395
- this.inspectionOverlay.enable();
396
- break;
397
- case 'performance':
398
- this.devTools.switchTab('performance');
399
- this.devTools.toggle();
400
- break;
401
- case 'components':
402
- this.devTools.switchTab('components');
403
- this.devTools.toggle();
404
- break;
405
- case 'highlight':
406
- if (args[0]) {
407
- this.devTools.highlightComponent(args[0]);
204
+
205
+ this.components = [];
206
+ // ✅ AJOUTER ICI :
207
+ this._cachedMaxScroll = 0;
208
+ this._maxScrollDirty = true;
209
+ this.resizeTimeout = null;
210
+
211
+ //this.applyThemeFromSystem();
212
+ this.state = {};
213
+ // NOUVELLE OPTION: choisir entre Canvas 2D et WebGL
214
+ this.useWebGL = options.useWebGL !== false; // true par défaut
215
+ // Initialiser le contexte approprié
216
+ if (this.useWebGL) {
217
+ try {
218
+ this.ctx = new WebGLCanvasAdapter(this.canvas);
219
+ } catch (e) {
220
+ this.ctx = this.canvas.getContext('2d');
221
+ this.useWebGL = false;
222
+ }
223
+ } else {
224
+ this.ctx = this.canvas.getContext('2d');
408
225
  }
409
- break;
410
- case 'reflow':
411
- this.components.forEach(comp => comp.markDirty());
412
- break;
413
- }
414
- }
415
-
416
- wrapContext(ctx, theme) {
417
- const originalFillStyle = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillStyle');
418
- Object.defineProperty(ctx, 'fillStyle', {
419
- set: (value) => {
420
- // Si value est blanc/noir ou une couleur “neutre”, tu remplaces par theme
421
- if (value === '#FFFFFF' || value === '#000000') {
422
- originalFillStyle.set.call(ctx, value);
226
+ // Calcule FPS
227
+ this.fps = 0;
228
+ this._frames = 0;
229
+ this._lastFpsTime = performance.now();
230
+ this.showFps = options.showFps || false; // false par défaut
231
+ this.debbug = options.debug || false; // false par défaut (et correction de la faute de frappe)
232
+
233
+ // Worker pour multithreading Canvas Worker
234
+ this.worker = this.createCanvasWorker();
235
+ this.worker.onmessage = this.handleWorkerMessage.bind(this);
236
+ this.worker.postMessage({
237
+ type: 'INIT',
238
+ payload: {
239
+ components: []
240
+ }
241
+ });
242
+
243
+ // Logic Worker
244
+ this.logicWorker = this.createLogicWorker();
245
+ this.logicWorker.onmessage = this.handleLogicWorkerMessage.bind(this);
246
+ this.logicWorkerState = {};
247
+ this.logicWorker.postMessage({
248
+ type: 'SET_STATE',
249
+ payload: this.state
250
+ });
251
+
252
+ // Envoyer l'état initial au worker
253
+ this.logicWorker.postMessage({
254
+ type: 'SET_STATE',
255
+ payload: this.state
256
+ });
257
+
258
+ // Gestion des événements
259
+ this.isDragging = false;
260
+ this.lastTouchY = 0;
261
+ this.scrollOffset = 0;
262
+ this.scrollVelocity = 0;
263
+ this.scrollFriction = 0.95;
264
+
265
+ // Optimisation
266
+ this.dirtyComponents = new Set();
267
+ this.optimizationEnabled = false;
268
+
269
+ // AJOUTER CETTE LIGNE
270
+ this.animator = new AnimationEngine();
271
+
272
+ // ===== NOUVEAU SYSTÈME DE ROUTING =====
273
+ this.routes = new Map();
274
+ this.currentRoute = '/';
275
+ this.currentParams = {};
276
+ this.currentQuery = {};
277
+ this.history = [];
278
+ this.historyIndex = -1;
279
+
280
+ // Animation de transition
281
+ this.transitionState = {
282
+ isTransitioning: false,
283
+ progress: 0,
284
+ duration: 300,
285
+ type: 'slide', // 'slide', 'fade', 'none'
286
+ direction: 'forward', // 'forward', 'back'
287
+ oldComponents: [],
288
+ newComponents: []
289
+ };
290
+
291
+ this.setupCanvas();
292
+ this.setupEventListeners();
293
+ this.setupHistoryListener();
294
+
295
+
296
+ this.startRenderLoop();
297
+
298
+ this.devTools = new DevTools(this);
299
+ this.inspectionOverlay = new InspectionOverlay(this);
300
+
301
+ // MODIFICATION: Vérifier explicitement l'option enableDevTools
302
+ const shouldEnableDevTools = options.enableDevTools === true;
303
+
304
+ if (shouldEnableDevTools) {
305
+ this.enableDevTools();
306
+ console.log('DevTools enabled. Press Ctrl+Shift+D to toggle.');
307
+ }
308
+
309
+ // Initialiser le ThemeManager
310
+ this.themeManager = new ThemeManager(this, {
311
+ lightTheme: options.lightTheme,
312
+ darkTheme: options.darkTheme,
313
+ storageKey: options.themeStorageKey || 'app-theme-mode'
314
+ });
315
+
316
+ // Raccourci pour accéder au thème actuel
317
+ this.theme = this.themeManager.getTheme();
318
+
319
+ // ✅ AJOUTER: Mesurer le temps d'init
320
+ const initTime = performance.now() - startTime;
321
+
322
+ // ✅ AJOUTER: Stocker les métriques
323
+ this.metrics = {
324
+ initTime: initTime,
325
+ firstRenderTime: null,
326
+ firstInteractionTime: null,
327
+ totalStartupTime: null
328
+ };
329
+
330
+ // ✅ AJOUTER: Logger si debug
331
+ if (options.debug || options.showMetrics) {
332
+ console.log(`⚡ Framework initialisé en ${initTime.toFixed(2)}ms`);
333
+ }
334
+
335
+ // ✅ AJOUTER: Marquer le premier rendu
336
+ this._firstRenderDone = false;
337
+ this._startupStartTime = startTime;
338
+ }
339
+
340
+ /**
341
+ * Affiche un écran de chargement animé
342
+ * @private
343
+ */
344
+ showSplashScreen() {
345
+ const startTime = performance.now();
346
+ const opts = this.splashOptions;
347
+
348
+ // ✅ Charger le logo si présent
349
+ let logoImage = null;
350
+ if (opts.logo) {
351
+ logoImage = new Image();
352
+ logoImage.src = opts.logo;
353
+ }
354
+
355
+ const animate = () => {
356
+ const elapsed = performance.now() - startTime;
357
+ const progress = Math.min(elapsed / opts.duration, 1);
358
+
359
+ // Clear
360
+ this.ctx.clearRect(0, 0, this.width, this.height);
361
+
362
+ // ✅ Background (gradient ou couleur unie)
363
+ if (Array.isArray(opts.backgroundColor) && opts.backgroundColor.length >= 2) {
364
+ // Gradient
365
+ const gradient = this.ctx.createLinearGradient(0, 0, this.width, this.height);
366
+ gradient.addColorStop(0, opts.backgroundColor[0]);
367
+ gradient.addColorStop(1, opts.backgroundColor[1]);
368
+ this.ctx.fillStyle = gradient;
369
+ } else {
370
+ // Couleur unie
371
+ this.ctx.fillStyle = opts.backgroundColor;
372
+ }
373
+ this.ctx.fillRect(0, 0, this.width, this.height);
374
+
375
+ const centerX = this.width / 2;
376
+ const centerY = this.height / 2;
377
+
378
+ // ✅ Logo (si présent et chargé)
379
+ if (logoImage && logoImage.complete) {
380
+ const logoX = centerX - opts.logoWidth / 2;
381
+ const logoY = centerY - opts.logoHeight - 80;
382
+ this.ctx.drawImage(logoImage, logoX, logoY, opts.logoWidth, opts.logoHeight);
383
+ }
384
+
385
+ // ✅ Spinner animé
386
+ const radius = 40;
387
+ const rotation = (elapsed / 1000) * Math.PI * 2;
388
+
389
+ // Cercle de fond
390
+ this.ctx.beginPath();
391
+ this.ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
392
+ this.ctx.strokeStyle = opts.spinnerBackground;
393
+ this.ctx.lineWidth = 4;
394
+ this.ctx.stroke();
395
+
396
+ // Arc animé
397
+ this.ctx.beginPath();
398
+ this.ctx.arc(centerX, centerY, radius, rotation, rotation + Math.PI * 1.5);
399
+ this.ctx.strokeStyle = opts.spinnerColor;
400
+ this.ctx.lineWidth = 4;
401
+ this.ctx.lineCap = 'round';
402
+ this.ctx.stroke();
403
+
404
+ // ✅ Texte personnalisé
405
+ this.ctx.fillStyle = opts.textColor;
406
+ this.ctx.font = `${opts.textSize}px ${opts.textFont}`;
407
+ this.ctx.textAlign = 'center';
408
+ this.ctx.fillText(opts.text, centerX, centerY + radius + 40);
409
+
410
+ // ✅ Barre de progression (optionnelle)
411
+ if (opts.showProgressBar) {
412
+ const barWidth = 200;
413
+ const barHeight = 4;
414
+ const barX = centerX - barWidth / 2;
415
+ const barY = centerY + radius + 70;
416
+
417
+ // Fond de la barre
418
+ this.ctx.fillStyle = opts.progressBarBackground;
419
+ this.ctx.fillRect(barX, barY, barWidth, barHeight);
420
+
421
+ // Progression
422
+ this.ctx.fillStyle = opts.progressBarColor;
423
+ this.ctx.fillRect(barX, barY, barWidth * progress, barHeight);
424
+ }
425
+
426
+ // Continuer ou fade out
427
+ if (progress < 1) {
428
+ requestAnimationFrame(animate);
429
+ } else {
430
+ this.fadeOutSplash();
431
+ }
432
+ };
433
+
434
+ animate();
435
+ }
436
+
437
+ /**
438
+ * Fade out du splash screen
439
+ * @private
440
+ */
441
+ fadeOutSplash() {
442
+ const opts = this.splashOptions;
443
+ const duration = opts.fadeOutDuration;
444
+ const startTime = performance.now();
445
+
446
+ const fade = () => {
447
+ const elapsed = performance.now() - startTime;
448
+ const progress = elapsed / duration;
449
+ const alpha = 1 - Math.min(progress, 1);
450
+
451
+ if (alpha > 0) {
452
+ this.ctx.clearRect(0, 0, this.width, this.height);
453
+ this.ctx.globalAlpha = alpha;
454
+
455
+ // Redessiner le background
456
+ if (Array.isArray(opts.backgroundColor) && opts.backgroundColor.length >= 2) {
457
+ const gradient = this.ctx.createLinearGradient(0, 0, this.width, this.height);
458
+ gradient.addColorStop(0, opts.backgroundColor[0]);
459
+ gradient.addColorStop(1, opts.backgroundColor[1]);
460
+ this.ctx.fillStyle = gradient;
461
+ } else {
462
+ this.ctx.fillStyle = opts.backgroundColor;
463
+ }
464
+ this.ctx.fillRect(0, 0, this.width, this.height);
465
+
466
+ // Spinner pendant le fade
467
+ const centerX = this.width / 2;
468
+ const centerY = this.height / 2;
469
+ const radius = 40;
470
+
471
+ this.ctx.beginPath();
472
+ this.ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
473
+ this.ctx.strokeStyle = opts.spinnerBackground;
474
+ this.ctx.lineWidth = 4;
475
+ this.ctx.stroke();
476
+
477
+ this.ctx.globalAlpha = 1;
478
+ requestAnimationFrame(fade);
479
+ } else {
480
+ this._splashFinished = true;
481
+ this.ctx.clearRect(0, 0, this.width, this.height);
482
+ }
483
+ };
484
+
485
+ fade();
486
+ }
487
+
488
+ // ✅ AJOUTER: Méthode pour mesurer le premier rendu
489
+ _markFirstRender() {
490
+ if (!this._firstRenderDone) {
491
+ this._firstRenderDone = true;
492
+ const firstRenderTime = performance.now() - this._startupStartTime - this.metrics.initTime;
493
+ this.metrics.firstRenderTime = firstRenderTime;
494
+ this.metrics.totalStartupTime = performance.now() - this._startupStartTime;
495
+
496
+ if (this.showMetrics) {
497
+ console.log(`🎨 Premier rendu en ${firstRenderTime.toFixed(2)}ms`);
498
+ console.log(`🚀 Temps total de démarrage: ${this.metrics.totalStartupTime.toFixed(2)}ms`);
499
+ this.displayMetrics();
500
+ }
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Écoute les changements système (ex: utilisateur bascule dark mode)
506
+ */
507
+ setupSystemThemeListener() {
508
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
509
+
510
+ // Ancienne méthode (compatibilité large)
511
+ if (mediaQuery.addEventListener) {
512
+ mediaQuery.addEventListener('change', (e) => {
513
+ if (this.themeMode === 'system') {
514
+ this.applyThemeFromSystem();
515
+ }
516
+ });
517
+ } else {
518
+ // Anciens navigateurs (rare en 2026)
519
+ mediaQuery.addListener((e) => {
520
+ if (this.themeMode === 'system') {
521
+ this.applyThemeFromSystem();
522
+ }
523
+ });
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Change le mode thème
529
+ * @param {'light'|'dark'|'system'} mode - Mode à appliquer
530
+ * @param {boolean} [save=true] - Sauvegarder le choix utilisateur ?
531
+ */
532
+ setThemeMode(mode) {
533
+ this.themeManager.setMode(mode);
534
+ this.theme = this.themeManager.getTheme();
535
+ }
536
+
537
+ /**
538
+ * Obtient une couleur du thème
539
+ */
540
+ getColor(colorName) {
541
+ return this.themeManager.getColor(colorName);
542
+ }
543
+
544
+ /**
545
+ * Ajoute un listener de changement de thème
546
+ */
547
+ onThemeChange(callback) {
548
+ this.themeManager.addListener((theme) => {
549
+ this.theme = theme;
550
+ callback(theme);
551
+ });
552
+ }
553
+
554
+ /**
555
+ * Bascule entre light et dark
556
+ */
557
+ toggleTheme() {
558
+ this.themeManager.toggle();
559
+ this.theme = this.themeManager.getTheme();
560
+ }
561
+
562
+ /**
563
+ * Active ou désactive les DevTools
564
+ * @param {boolean} enabled - true pour activer, false pour désactiver
565
+ */
566
+ enableDevTools(enabled = true) {
567
+ if (enabled) {
568
+ // Créer le DevTools s'il n'existe pas
569
+ if (!this.devTools) {
570
+ this.devTools = new DevTools(this);
571
+ }
572
+
573
+ // Attacher seulement si pas déjà fait
574
+ if (!this.devTools._isAttached) {
575
+ this.devTools.attachToFramework();
576
+ this.devTools._isAttached = true;
577
+ }
578
+
579
+ // Afficher le bouton
580
+ if (this.devTools.toggleBtn) {
581
+ this.devTools.toggleBtn.style.display = 'block';
582
+ }
423
583
  } else {
424
- originalFillStyle.set.call(ctx, value);
584
+ // Désactiver complètement
585
+ if (this.devTools) {
586
+ // Détacher du framework
587
+ if (this.devTools.detachFromFramework) {
588
+ this.devTools.detachFromFramework();
589
+ } else if (this.devTools.cleanup) {
590
+ this.devTools.cleanup();
591
+ }
592
+
593
+ // Supprimer de la page DOM
594
+ if (this.devTools.container && this.devTools.container.parentNode) {
595
+ this.devTools.container.parentNode.removeChild(this.devTools.container);
596
+ }
597
+
598
+ if (this.devTools.toggleBtn && this.devTools.toggleBtn.parentNode) {
599
+ this.devTools.toggleBtn.parentNode.removeChild(this.devTools.toggleBtn);
600
+ }
601
+
602
+ this.devTools._isAttached = false;
603
+ }
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Bascule l'overlay d'inspection
609
+ */
610
+ toggleInspection() {
611
+ this.inspectionOverlay.toggle();
612
+ }
613
+
614
+ /**
615
+ * Exécute une commande DevTools
616
+ */
617
+ devToolsCommand(command, ...args) {
618
+ switch (command) {
619
+ case 'inspect':
620
+ this.inspectionOverlay.enable();
621
+ break;
622
+ case 'performance':
623
+ this.devTools.switchTab('performance');
624
+ this.devTools.toggle();
625
+ break;
626
+ case 'components':
627
+ this.devTools.switchTab('components');
628
+ this.devTools.toggle();
629
+ break;
630
+ case 'highlight':
631
+ if (args[0]) {
632
+ this.devTools.highlightComponent(args[0]);
633
+ }
634
+ break;
635
+ case 'reflow':
636
+ this.components.forEach(comp => comp.markDirty());
637
+ break;
425
638
  }
426
- },
427
- get: () => originalFillStyle.get.call(ctx)
428
- });
429
- }
430
-
431
- createCanvasWorker() {
432
- const workerCode = `
639
+ }
640
+
641
+ wrapContext(ctx, theme) {
642
+ const originalFillStyle = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillStyle');
643
+ Object.defineProperty(ctx, 'fillStyle', {
644
+ set: (value) => {
645
+ // Si value est blanc/noir ou une couleur “neutre”, tu remplaces par theme
646
+ if (value === '#FFFFFF' || value === '#000000') {
647
+ originalFillStyle.set.call(ctx, value);
648
+ } else {
649
+ originalFillStyle.set.call(ctx, value);
650
+ }
651
+ },
652
+ get: () => originalFillStyle.get.call(ctx)
653
+ });
654
+ }
655
+
656
+ createCanvasWorker() {
657
+ const workerCode = `
433
658
  let components = [];
434
659
 
435
660
  self.onmessage = function(e) {
@@ -461,13 +686,15 @@ class CanvasFramework {
461
686
  }
462
687
  };
463
688
  `;
464
-
465
- const blob = new Blob([workerCode], { type: 'application/javascript' });
466
- return new Worker(URL.createObjectURL(blob));
467
- }
468
689
 
469
- createLogicWorker() {
470
- const workerCode = `
690
+ const blob = new Blob([workerCode], {
691
+ type: 'application/javascript'
692
+ });
693
+ return new Worker(URL.createObjectURL(blob));
694
+ }
695
+
696
+ createLogicWorker() {
697
+ const workerCode = `
471
698
  let state = {};
472
699
 
473
700
  self.onmessage = async function(e) {
@@ -491,1122 +718,1209 @@ class CanvasFramework {
491
718
  }
492
719
  };
493
720
  `;
494
-
495
- const blob = new Blob([workerCode], { type: 'application/javascript' });
496
- return new Worker(URL.createObjectURL(blob));
497
- }
498
-
499
- // Set Theme dynamique
500
- setTheme(theme) {
501
- this.theme = theme;
502
-
503
- if (!this.useWebGL) {
504
- this.wrapContext(this.ctx, theme);
505
- }
506
-
507
- // Protège la boucle
508
- if (this.components && Array.isArray(this.components)) {
509
- this.components.forEach(comp => comp.markDirty());
510
- } else {
511
- console.warn('[setTheme] components pas encore initialisé');
512
- }
513
- }
514
-
515
- // Switch Theme
516
- toggleDarkMode() {
517
- if (this.theme === lightTheme) {
518
- this.setTheme(darkTheme);
519
- } else {
520
- this.setTheme(lightTheme);
521
- }
522
- }
523
-
524
- enableFpsDisplay(enable = true) {
525
- this.showFps = enable;
526
- }
527
-
528
- // AJOUTER CETTE MÉTHODE (optionnel - pour faciliter l'accès)
529
- animate(component, options) {
530
- return this.animator.animate(component, options);
531
- }
532
-
533
- // ----- Worker UI -----
534
- handleWorkerMessage(e) {
535
- const { type, payload } = e.data;
536
- switch(type) {
537
- case 'LAYOUT_DONE':
538
- for (let update of payload) {
539
- const comp = this.components.find(c => c.id === update.id);
540
- if (comp) comp.height = update.height;
721
+
722
+ const blob = new Blob([workerCode], {
723
+ type: 'application/javascript'
724
+ });
725
+ return new Worker(URL.createObjectURL(blob));
726
+ }
727
+
728
+ // Set Theme dynamique
729
+ setTheme(theme) {
730
+ this.theme = theme;
731
+
732
+ if (!this.useWebGL) {
733
+ this.wrapContext(this.ctx, theme);
541
734
  }
542
- break;
543
- case 'SCROLL_UPDATED':
544
- this.scrollOffset = payload.offset;
545
- this.scrollVelocity = payload.velocity;
546
- break;
547
- }
548
- }
549
-
550
- updateLayoutAsync() {
551
- this.worker.postMessage({ type: 'UPDATE_LAYOUT' });
552
- }
553
-
554
- updateScrollInertia() {
555
- const maxScroll = this.getMaxScroll();
556
- this.worker.postMessage({
557
- type: 'SCROLL_INERTIA',
558
- payload: {
559
- offset: this.scrollOffset,
560
- velocity: this.scrollVelocity,
561
- friction: this.scrollFriction,
562
- maxScroll
563
- }
564
- });
565
- }
566
-
567
- // ------ Logic Worker --------
568
- handleLogicWorkerMessage(e) {
569
- const { type, payload } = e.data;
570
- switch(type) {
571
- case 'STATE_UPDATED':
572
- // Le worker a renvoyé le nouvel état global
573
- this.logicWorkerState = payload;
574
- break;
575
-
576
- case 'EXECUTION_RESULT':
577
- // Résultat d'une tâche spécifique envoyée au worker
578
- if (this.onWorkerResult) this.onWorkerResult(payload);
579
- break;
580
-
581
- case 'EXECUTION_ERROR':
582
- console.error('Logic Worker Error:', payload);
583
- break;
584
- }
585
- }
586
-
587
- runLogicTask(taskName, taskData) {
588
- this.logicWorker.postMessage({
589
- type: 'EXECUTE_TASK',
590
- payload: { taskName, taskData }
591
- });
592
- }
593
-
594
- updateLogicWorkerState(newState) {
595
- this.logicWorkerState = { ...this.logicWorkerState, ...newState };
596
- this.logicWorker.postMessage({ type: 'SET_STATE', payload: this.logicWorkerState });
597
- }
598
-
599
- detectPlatform() {
600
- const ua = navigator.userAgent.toLowerCase();
601
- if (/android/.test(ua)) return 'material';
602
- if (/iphone|ipad|ipod/.test(ua)) return 'cupertino';
603
- return 'material';
604
- }
605
-
606
- setupCanvas() {
607
- this.canvas.width = this.width * this.dpr;
608
- this.canvas.height = this.height * this.dpr;
609
- this.canvas.style.width = this.width + 'px';
610
- this.canvas.style.height = this.height + 'px';
611
-
612
- // Échelle uniquement pour Canvas 2D
613
- if (!this.useWebGL) {
614
- this.ctx.scale(this.dpr, this.dpr);
615
- } else {
616
- // WebGL gère le DPR automatiquement via la matrice de projection
617
- this.ctx.updateProjectionMatrix();
618
- }
619
- }
620
-
621
- setupEventListeners() {
622
- this.canvas.addEventListener('touchstart', this.handleTouchStart.bind(this));
623
- this.canvas.addEventListener('touchmove', this.handleTouchMove.bind(this));
624
- this.canvas.addEventListener('touchend', this.handleTouchEnd.bind(this));
625
- this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
626
- this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
627
- this.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
628
- window.addEventListener('resize', this.handleResize.bind(this));
629
- }
630
-
631
- /**
632
- * Configure l'écoute de l'historique du navigateur
633
- * @private
634
- */
635
- setupHistoryListener() {
636
- window.addEventListener('popstate', (e) => {
637
- if (e.state && e.state.route) {
638
- this.navigateTo(e.state.route, {
639
- replace: true,
640
- animate: true,
641
- direction: 'back'
735
+
736
+ // Protège la boucle
737
+ if (this.components && Array.isArray(this.components)) {
738
+ this.components.forEach(comp => comp.markDirty());
739
+ } else {
740
+ console.warn('[setTheme] components pas encore initialisé');
741
+ }
742
+ }
743
+
744
+ // Switch Theme
745
+ toggleDarkMode() {
746
+ if (this.theme === lightTheme) {
747
+ this.setTheme(darkTheme);
748
+ } else {
749
+ this.setTheme(lightTheme);
750
+ }
751
+ }
752
+
753
+ enableFpsDisplay(enable = true) {
754
+ this.showFps = enable;
755
+ }
756
+
757
+ // AJOUTER CETTE MÉTHODE (optionnel - pour faciliter l'accès)
758
+ animate(component, options) {
759
+ return this.animator.animate(component, options);
760
+ }
761
+
762
+ // ----- Worker UI -----
763
+ handleWorkerMessage(e) {
764
+ const {
765
+ type,
766
+ payload
767
+ } = e.data;
768
+ switch (type) {
769
+ case 'LAYOUT_DONE':
770
+ for (let update of payload) {
771
+ const comp = this.components.find(c => c.id === update.id);
772
+ if (comp) comp.height = update.height;
773
+ }
774
+ break;
775
+ case 'SCROLL_UPDATED':
776
+ this.scrollOffset = payload.offset;
777
+ this.scrollVelocity = payload.velocity;
778
+ break;
779
+ }
780
+ }
781
+
782
+ updateLayoutAsync() {
783
+ this.worker.postMessage({
784
+ type: 'UPDATE_LAYOUT'
642
785
  });
643
- }
644
- });
645
- }
646
-
647
- // ===== MÉTHODES DE ROUTING =====
648
-
649
- /**
650
- * Définit une route avec pattern de paramètres
651
- * @param {string} pattern - Pattern de la route (ex: '/user/:id', '/posts/:category/:id')
652
- * @param {Function} component - Fonction qui crée les composants
653
- * @param {Object} options - Options de la route
654
- * @returns {CanvasFramework}
655
- */
656
- route(pattern, component, options = {}) {
657
- const route = {
658
- pattern,
659
- component,
660
- regex: this.patternToRegex(pattern),
661
- paramNames: this.extractParamNames(pattern),
662
- beforeEnter: options.beforeEnter,
663
- afterEnter: options.afterEnter,
664
- beforeLeave: options.beforeLeave,
665
- afterLeave: options.afterLeave, // ✅ NOUVEAU
666
- onEnter: options.onEnter, // ✅ NOUVEAU (alias de afterEnter)
667
- onLeave: options.onLeave, // ✅ NOUVEAU (alias de beforeLeave)
668
- transition: options.transition || 'slide'
669
- };
670
-
671
- this.routes.set(pattern, route);
672
- return this;
673
- }
674
-
675
- /**
676
- * Convertit un pattern de route en regex
677
- * @private
678
- */
679
- patternToRegex(pattern) {
680
- const regexPattern = pattern
681
- .replace(/\//g, '\\/')
682
- .replace(/:([^\/]+)/g, '([^\\/]+)');
683
- return new RegExp(`^${regexPattern}$`);
684
- }
685
-
686
- /**
687
- * Extrait les noms des paramètres d'un pattern
688
- * @private
689
- */
690
- extractParamNames(pattern) {
691
- const matches = pattern.match(/:([^\/]+)/g);
692
- return matches ? matches.map(m => m.slice(1)) : [];
693
- }
694
-
695
- /**
696
- * Trouve la route correspondant à un path
697
- * @private
698
- */
699
- matchRoute(path) {
700
- // Séparer le path et la query string
701
- const [pathname, queryString] = path.split('?');
702
-
703
- for (let [pattern, route] of this.routes) {
704
- const match = pathname.match(route.regex);
705
- if (match) {
706
- const params = {};
707
- route.paramNames.forEach((name, index) => {
708
- params[name] = match[index + 1];
786
+ }
787
+
788
+ updateScrollInertia() {
789
+ const maxScroll = this.getMaxScroll();
790
+ this.worker.postMessage({
791
+ type: 'SCROLL_INERTIA',
792
+ payload: {
793
+ offset: this.scrollOffset,
794
+ velocity: this.scrollVelocity,
795
+ friction: this.scrollFriction,
796
+ maxScroll
797
+ }
709
798
  });
710
-
711
- const query = this.parseQueryString(queryString);
712
-
713
- return { route, params, query, pathname };
714
- }
715
799
  }
716
- return null;
717
- }
718
800
 
719
- /**
720
- * Parse une query string
721
- * @private
722
- */
723
- parseQueryString(queryString) {
724
- if (!queryString) return {};
725
-
726
- const params = {};
727
- queryString.split('&').forEach(param => {
728
- const [key, value] = param.split('=');
729
- params[decodeURIComponent(key)] = decodeURIComponent(value || '');
730
- });
731
- return params;
732
- }
733
-
734
- /**
735
- * Navigue vers une route
736
- * @param {string} path - Chemin de destination (ex: '/user/123', '/posts/tech/456?sort=date')
737
- * @param {Object} options - Options de navigation
738
- */
739
- navigate(path, options = {}) {
740
- this.navigateTo(path, options);
741
- }
742
-
743
- /**
744
- * Méthode interne de navigation
745
- * @private
746
- */
747
- async navigateTo(path, options = {}) {
748
- const {
749
- replace = false,
750
- animate = true,
751
- direction = 'forward',
752
- transition = null,
753
- state = {}
754
- } = options;
755
-
756
- const match = this.matchRoute(path);
757
- if (!match) {
758
- console.warn(`Route not found: ${path}`);
759
- return;
760
- }
761
-
762
- const { route, params, query, pathname } = match;
763
-
764
- // ===== LIFECYCLE: AVANT DE QUITTER L'ANCIENNE ROUTE =====
765
-
766
- // Hook beforeLeave de la route actuelle (peut bloquer la navigation)
767
- const currentRouteData = this.routes.get(this.currentRoute);
768
- if (currentRouteData?.beforeLeave) {
769
- const canLeave = await currentRouteData.beforeLeave(this.currentParams, this.currentQuery);
770
- if (canLeave === false) {
771
- console.log('Navigation cancelled by beforeLeave hook');
772
- return;
773
- }
801
+ // ------ Logic Worker --------
802
+ handleLogicWorkerMessage(e) {
803
+ const {
804
+ type,
805
+ payload
806
+ } = e.data;
807
+ switch (type) {
808
+ case 'STATE_UPDATED':
809
+ // Le worker a renvoyé le nouvel état global
810
+ this.logicWorkerState = payload;
811
+ break;
812
+
813
+ case 'EXECUTION_RESULT':
814
+ // Résultat d'une tâche spécifique envoyée au worker
815
+ if (this.onWorkerResult) this.onWorkerResult(payload);
816
+ break;
817
+
818
+ case 'EXECUTION_ERROR':
819
+ console.error('Logic Worker Error:', payload);
820
+ break;
821
+ }
774
822
  }
775
-
776
- // ✅ NOUVEAU : Hook onLeave (alias plus intuitif de beforeLeave, mais ne bloque pas)
777
- if (currentRouteData?.onLeave) {
778
- await currentRouteData.onLeave(this.currentParams, this.currentQuery);
823
+
824
+ runLogicTask(taskName, taskData) {
825
+ this.logicWorker.postMessage({
826
+ type: 'EXECUTE_TASK',
827
+ payload: {
828
+ taskName,
829
+ taskData
830
+ }
831
+ });
779
832
  }
780
833
 
781
- // ===== LIFECYCLE: AVANT D'ENTRER DANS LA NOUVELLE ROUTE =====
782
-
783
- // Hook beforeEnter de la nouvelle route (peut bloquer la navigation)
784
- if (route.beforeEnter) {
785
- const canEnter = await route.beforeEnter(params, query);
786
- if (canEnter === false) {
787
- console.log('Navigation cancelled by beforeEnter hook');
788
- return;
789
- }
834
+ updateLogicWorkerState(newState) {
835
+ this.logicWorkerState = {
836
+ ...this.logicWorkerState,
837
+ ...newState
838
+ };
839
+ this.logicWorker.postMessage({
840
+ type: 'SET_STATE',
841
+ payload: this.logicWorkerState
842
+ });
790
843
  }
791
-
792
- // ✅ NOUVEAU : Hook onEnter (appelé juste avant de créer les composants)
793
- if (route.onEnter) {
794
- await route.onEnter(params, query);
844
+
845
+ detectPlatform() {
846
+ const ua = navigator.userAgent.toLowerCase();
847
+ if (/android/.test(ua)) return 'material';
848
+ if (/iphone|ipad|ipod/.test(ua)) return 'cupertino';
849
+ return 'material';
795
850
  }
796
851
 
797
- // ===== SAUVEGARDER L'ÉTAT ACTUEL =====
798
-
799
- // Sauvegarder l'ancienne route pour l'animation et les hooks
800
- const oldComponents = [...this.components];
801
- const oldRoute = this.currentRoute;
802
- const oldParams = { ...this.currentParams };
803
- const oldQuery = { ...this.currentQuery };
852
+ setupCanvas() {
853
+ this.canvas.width = this.width * this.dpr;
854
+ this.canvas.height = this.height * this.dpr;
855
+ this.canvas.style.width = this.width + 'px';
856
+ this.canvas.style.height = this.height + 'px';
804
857
 
805
- // ===== METTRE À JOUR L'ÉTAT =====
806
-
807
- this.currentRoute = pathname;
808
- this.currentParams = params;
809
- this.currentQuery = query;
858
+ // Échelle uniquement pour Canvas 2D
859
+ if (!this.useWebGL) {
860
+ this.ctx.scale(this.dpr, this.dpr);
861
+ } else {
862
+ // WebGL gère le DPR automatiquement via la matrice de projection
863
+ this.ctx.updateProjectionMatrix();
864
+ }
865
+ }
810
866
 
811
- // ===== GÉRER L'HISTORIQUE =====
812
-
813
- if (!replace) {
814
- this.historyIndex++;
815
- this.history = this.history.slice(0, this.historyIndex);
816
- this.history.push({ path, params, query, state });
817
-
818
- // Mettre à jour l'historique du navigateur
819
- window.history.pushState(
820
- { route: path, params, query, state },
821
- '',
822
- path
823
- );
824
- } else {
825
- this.history[this.historyIndex] = { path, params, query, state };
826
- window.history.replaceState(
827
- { route: path, params, query, state },
828
- '',
829
- path
830
- );
831
- }
832
-
833
- // ===== CRÉER LES NOUVEAUX COMPOSANTS =====
834
-
835
- this.components = [];
836
- if (typeof route.component === 'function') {
837
- route.component(this, params, query);
838
- }
839
-
840
- // ===== LANCER L'ANIMATION DE TRANSITION =====
841
-
842
- if (animate && !this.transitionState.isTransitioning) {
843
- const transitionType = transition || route.transition || 'slide';
844
- this.startTransition(oldComponents, this.components, transitionType, direction);
845
- }
846
-
847
- // ===== LIFECYCLE: APRÈS ÊTRE ENTRÉ DANS LA NOUVELLE ROUTE =====
848
-
849
- // Hook afterEnter (appelé immédiatement après la création des composants)
850
- if (route.afterEnter) {
851
- route.afterEnter(params, query);
852
- }
853
-
854
- // ✅ NOUVEAU : Hook afterLeave de l'ancienne route (après transition complète)
855
- if (currentRouteData?.afterLeave) {
856
- // Si animation, attendre la fin de la transition
857
- if (animate && this.transitionState.isTransitioning) {
858
- setTimeout(() => {
859
- currentRouteData.afterLeave(oldParams, oldQuery);
860
- }, this.transitionState.duration || 300);
861
- } else {
862
- // Pas d'animation, appeler immédiatement
863
- currentRouteData.afterLeave(oldParams, oldQuery);
864
- }
867
+ setupEventListeners() {
868
+ this.canvas.addEventListener('touchstart', this.handleTouchStart.bind(this));
869
+ this.canvas.addEventListener('touchmove', this.handleTouchMove.bind(this));
870
+ this.canvas.addEventListener('touchend', this.handleTouchEnd.bind(this));
871
+ this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
872
+ this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
873
+ this.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
874
+ window.addEventListener('resize', this.handleResize.bind(this));
865
875
  }
866
-
867
- // ✅ OPTIONNEL : Marquer les composants comme "dirty" pour forcer le rendu
868
- this._maxScrollDirty = true;
869
- }
870
-
871
- /**
872
- * Démarre une animation de transition
873
- * @private
874
- */
875
- startTransition(oldComponents, newComponents, type, direction) {
876
- this.transitionState = {
877
- isTransitioning: true,
878
- progress: 0,
879
- duration: 300,
880
- type,
881
- direction,
882
- oldComponents: [...oldComponents],
883
- newComponents: [...newComponents],
884
- startTime: Date.now()
885
- };
886
- }
887
-
888
- /**
889
- * Met à jour l'animation de transition
890
- * @private
891
- */
892
- updateTransition() {
893
- if (!this.transitionState.isTransitioning) return;
894
-
895
- const elapsed = Date.now() - this.transitionState.startTime;
896
- this.transitionState.progress = Math.min(elapsed / this.transitionState.duration, 1);
897
-
898
- // Fonction d'easing (ease-in-out)
899
- const eased = this.easeInOutCubic(this.transitionState.progress);
900
-
901
- // Appliquer la transformation selon le type
902
- this.ctx.save();
903
- this.applyTransitionTransform(eased);
904
- this.ctx.restore();
905
-
906
- // Terminer la transition
907
- if (this.transitionState.progress >= 1) {
908
- this.transitionState.isTransitioning = false;
909
- this.transitionState.oldComponents = [];
910
- }
911
- }
912
-
913
- /**
914
- * Applique la transformation de transition
915
- * @private
916
- */
917
- applyTransitionTransform(progress) {
918
- const { type, direction, oldComponents, newComponents } = this.transitionState;
919
- const directionMultiplier = direction === 'forward' ? 1 : -1;
920
-
921
- switch (type) {
922
- case 'slide':
923
- // Dessiner l'ancienne vue qui sort
924
- this.ctx.save();
925
- this.ctx.translate(-this.width * progress * directionMultiplier, 0);
926
- this.ctx.globalAlpha = 1 - progress * 0.3;
927
- for (let comp of oldComponents) {
928
- if (comp.visible) comp.draw(this.ctx);
876
+
877
+ /**
878
+ * Configure l'écoute de l'historique du navigateur
879
+ * @private
880
+ */
881
+ setupHistoryListener() {
882
+ window.addEventListener('popstate', (e) => {
883
+ if (e.state && e.state.route) {
884
+ this.navigateTo(e.state.route, {
885
+ replace: true,
886
+ animate: true,
887
+ direction: 'back'
888
+ });
889
+ }
890
+ });
891
+ }
892
+
893
+ // ===== MÉTHODES DE ROUTING =====
894
+
895
+ /**
896
+ * Définit une route avec pattern de paramètres
897
+ * @param {string} pattern - Pattern de la route (ex: '/user/:id', '/posts/:category/:id')
898
+ * @param {Function} component - Fonction qui crée les composants
899
+ * @param {Object} options - Options de la route
900
+ * @returns {CanvasFramework}
901
+ */
902
+ route(pattern, component, options = {}) {
903
+ const route = {
904
+ pattern,
905
+ component,
906
+ regex: this.patternToRegex(pattern),
907
+ paramNames: this.extractParamNames(pattern),
908
+ beforeEnter: options.beforeEnter,
909
+ afterEnter: options.afterEnter,
910
+ beforeLeave: options.beforeLeave,
911
+ afterLeave: options.afterLeave, // NOUVEAU
912
+ onEnter: options.onEnter, // ✅ NOUVEAU (alias de afterEnter)
913
+ onLeave: options.onLeave, // ✅ NOUVEAU (alias de beforeLeave)
914
+ transition: options.transition || 'slide'
915
+ };
916
+
917
+ this.routes.set(pattern, route);
918
+ return this;
919
+ }
920
+
921
+ /**
922
+ * Convertit un pattern de route en regex
923
+ * @private
924
+ */
925
+ patternToRegex(pattern) {
926
+ const regexPattern = pattern
927
+ .replace(/\//g, '\\/')
928
+ .replace(/:([^\/]+)/g, '([^\\/]+)');
929
+ return new RegExp(`^${regexPattern}$`);
930
+ }
931
+
932
+ /**
933
+ * Extrait les noms des paramètres d'un pattern
934
+ * @private
935
+ */
936
+ extractParamNames(pattern) {
937
+ const matches = pattern.match(/:([^\/]+)/g);
938
+ return matches ? matches.map(m => m.slice(1)) : [];
939
+ }
940
+
941
+ /**
942
+ * Trouve la route correspondant à un path
943
+ * @private
944
+ */
945
+ matchRoute(path) {
946
+ // Séparer le path et la query string
947
+ const [pathname, queryString] = path.split('?');
948
+
949
+ for (let [pattern, route] of this.routes) {
950
+ const match = pathname.match(route.regex);
951
+ if (match) {
952
+ const params = {};
953
+ route.paramNames.forEach((name, index) => {
954
+ params[name] = match[index + 1];
955
+ });
956
+
957
+ const query = this.parseQueryString(queryString);
958
+
959
+ return {
960
+ route,
961
+ params,
962
+ query,
963
+ pathname
964
+ };
965
+ }
929
966
  }
930
- this.ctx.restore();
967
+ return null;
968
+ }
931
969
 
932
- // Dessiner la nouvelle vue qui entre
933
- this.ctx.save();
934
- this.ctx.translate(this.width * (1 - progress) * directionMultiplier, 0);
935
- for (let comp of newComponents) {
936
- if (comp.visible) comp.draw(this.ctx);
970
+ /**
971
+ * Parse une query string
972
+ * @private
973
+ */
974
+ parseQueryString(queryString) {
975
+ if (!queryString) return {};
976
+
977
+ const params = {};
978
+ queryString.split('&').forEach(param => {
979
+ const [key, value] = param.split('=');
980
+ params[decodeURIComponent(key)] = decodeURIComponent(value || '');
981
+ });
982
+ return params;
983
+ }
984
+
985
+ /**
986
+ * Navigue vers une route
987
+ * @param {string} path - Chemin de destination (ex: '/user/123', '/posts/tech/456?sort=date')
988
+ * @param {Object} options - Options de navigation
989
+ */
990
+ navigate(path, options = {}) {
991
+ this.navigateTo(path, options);
992
+ }
993
+
994
+ /**
995
+ * Méthode interne de navigation
996
+ * @private
997
+ */
998
+ async navigateTo(path, options = {}) {
999
+ const {
1000
+ replace = false,
1001
+ animate = true,
1002
+ direction = 'forward',
1003
+ transition = null,
1004
+ state = {}
1005
+ } = options;
1006
+
1007
+ const match = this.matchRoute(path);
1008
+ if (!match) {
1009
+ console.warn(`Route not found: ${path}`);
1010
+ return;
937
1011
  }
938
- this.ctx.restore();
939
- break;
940
1012
 
941
- case 'fade':
942
- // Dessiner l'ancienne vue qui fade out
943
- this.ctx.save();
944
- this.ctx.globalAlpha = 1 - progress;
945
- for (let comp of oldComponents) {
946
- if (comp.visible) comp.draw(this.ctx);
1013
+ const {
1014
+ route,
1015
+ params,
1016
+ query,
1017
+ pathname
1018
+ } = match;
1019
+
1020
+ // ===== LIFECYCLE: AVANT DE QUITTER L'ANCIENNE ROUTE =====
1021
+
1022
+ // Hook beforeLeave de la route actuelle (peut bloquer la navigation)
1023
+ const currentRouteData = this.routes.get(this.currentRoute);
1024
+ if (currentRouteData?.beforeLeave) {
1025
+ const canLeave = await currentRouteData.beforeLeave(this.currentParams, this.currentQuery);
1026
+ if (canLeave === false) {
1027
+ console.log('Navigation cancelled by beforeLeave hook');
1028
+ return;
1029
+ }
947
1030
  }
948
- this.ctx.restore();
949
1031
 
950
- // Dessiner la nouvelle vue qui fade in
951
- this.ctx.save();
952
- this.ctx.globalAlpha = progress;
953
- for (let comp of newComponents) {
954
- if (comp.visible) comp.draw(this.ctx);
1032
+ // NOUVEAU : Hook onLeave (alias plus intuitif de beforeLeave, mais ne bloque pas)
1033
+ if (currentRouteData?.onLeave) {
1034
+ await currentRouteData.onLeave(this.currentParams, this.currentQuery);
1035
+ }
1036
+
1037
+ // ===== LIFECYCLE: AVANT D'ENTRER DANS LA NOUVELLE ROUTE =====
1038
+
1039
+ // Hook beforeEnter de la nouvelle route (peut bloquer la navigation)
1040
+ if (route.beforeEnter) {
1041
+ const canEnter = await route.beforeEnter(params, query);
1042
+ if (canEnter === false) {
1043
+ console.log('Navigation cancelled by beforeEnter hook');
1044
+ return;
1045
+ }
955
1046
  }
956
- this.ctx.restore();
957
- break;
958
1047
 
959
- case 'none':
960
- // Pas d'animation, juste afficher la nouvelle vue
961
- for (let comp of newComponents) {
962
- if (comp.visible) comp.draw(this.ctx);
1048
+ // ✅ NOUVEAU : Hook onEnter (appelé juste avant de créer les composants)
1049
+ if (route.onEnter) {
1050
+ await route.onEnter(params, query);
963
1051
  }
964
- break;
965
- }
966
- }
967
-
968
- /**
969
- * Fonction d'easing
970
- * @private
971
- */
972
- easeInOutCubic(t) {
973
- return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
974
- }
975
-
976
- /**
977
- * Retour en arrière dans l'historique
978
- */
979
- goBack() {
980
- if (this.historyIndex > 0) {
981
- this.historyIndex--;
982
- const historyEntry = this.history[this.historyIndex];
983
- this.navigateTo(historyEntry.path, {
984
- replace: true,
985
- animate: true,
986
- direction: 'back'
987
- });
988
- window.history.back();
989
- }
990
- }
991
-
992
- /**
993
- * Avancer dans l'historique
994
- */
995
- goForward() {
996
- if (this.historyIndex < this.history.length - 1) {
997
- this.historyIndex++;
998
- const historyEntry = this.history[this.historyIndex];
999
- this.navigateTo(historyEntry.path, {
1000
- replace: true,
1001
- animate: true,
1002
- direction: 'forward'
1003
- });
1004
- window.history.forward();
1005
- }
1006
- }
1007
-
1008
- /**
1009
- * Obtient les paramètres de la route actuelle
1010
- * @returns {Object}
1011
- */
1012
- getParams() {
1013
- return { ...this.currentParams };
1014
- }
1015
-
1016
- /**
1017
- * Obtient la query string de la route actuelle
1018
- * @returns {Object}
1019
- */
1020
- getQuery() {
1021
- return { ...this.currentQuery };
1022
- }
1023
-
1024
- /**
1025
- * Obtient un paramètre spécifique
1026
- * @param {string} name
1027
- * @returns {string|undefined}
1028
- */
1029
- getParam(name) {
1030
- return this.currentParams[name];
1031
- }
1032
-
1033
- /**
1034
- * Obtient un paramètre de query spécifique
1035
- * @param {string} name
1036
- * @returns {string|undefined}
1037
- */
1038
- getQueryParam(name) {
1039
- return this.currentQuery[name];
1040
- }
1041
-
1042
- // ===== FIN DES MÉTHODES DE ROUTING =====
1043
-
1044
- handleTouchStart(e) {
1045
- e.preventDefault();
1046
- this.isDragging = false;
1047
- const touch = e.touches[0];
1048
- const pos = this.getTouchPos(touch);
1049
- this.lastTouchY = pos.y;
1050
- this.checkComponentsAtPosition(pos.x, pos.y, 'start');
1051
- }
1052
-
1053
- handleTouchMove(e) {
1054
- e.preventDefault();
1055
- const touch = e.touches[0];
1056
- const pos = this.getTouchPos(touch);
1057
-
1058
- if (!this.isDragging) {
1059
- const deltaY = Math.abs(pos.y - this.lastTouchY);
1060
- if (deltaY > 5) {
1061
- this.isDragging = true;
1062
- }
1052
+
1053
+ // ===== SAUVEGARDER L'ÉTAT ACTUEL =====
1054
+
1055
+ // Sauvegarder l'ancienne route pour l'animation et les hooks
1056
+ const oldComponents = [...this.components];
1057
+ const oldRoute = this.currentRoute;
1058
+ const oldParams = {
1059
+ ...this.currentParams
1060
+ };
1061
+ const oldQuery = {
1062
+ ...this.currentQuery
1063
+ };
1064
+
1065
+ // ===== METTRE À JOUR L'ÉTAT =====
1066
+
1067
+ this.currentRoute = pathname;
1068
+ this.currentParams = params;
1069
+ this.currentQuery = query;
1070
+
1071
+ // ===== GÉRER L'HISTORIQUE =====
1072
+
1073
+ if (!replace) {
1074
+ this.historyIndex++;
1075
+ this.history = this.history.slice(0, this.historyIndex);
1076
+ this.history.push({
1077
+ path,
1078
+ params,
1079
+ query,
1080
+ state
1081
+ });
1082
+
1083
+ // Mettre à jour l'historique du navigateur
1084
+ window.history.pushState({
1085
+ route: path,
1086
+ params,
1087
+ query,
1088
+ state
1089
+ },
1090
+ '',
1091
+ path
1092
+ );
1093
+ } else {
1094
+ this.history[this.historyIndex] = {
1095
+ path,
1096
+ params,
1097
+ query,
1098
+ state
1099
+ };
1100
+ window.history.replaceState({
1101
+ route: path,
1102
+ params,
1103
+ query,
1104
+ state
1105
+ },
1106
+ '',
1107
+ path
1108
+ );
1109
+ }
1110
+
1111
+ // ===== CRÉER LES NOUVEAUX COMPOSANTS =====
1112
+
1113
+ this.components = [];
1114
+ if (typeof route.component === 'function') {
1115
+ route.component(this, params, query);
1116
+ }
1117
+
1118
+ // ===== LANCER L'ANIMATION DE TRANSITION =====
1119
+
1120
+ if (animate && !this.transitionState.isTransitioning) {
1121
+ const transitionType = transition || route.transition || 'slide';
1122
+ this.startTransition(oldComponents, this.components, transitionType, direction);
1123
+ }
1124
+
1125
+ // ===== LIFECYCLE: APRÈS ÊTRE ENTRÉ DANS LA NOUVELLE ROUTE =====
1126
+
1127
+ // Hook afterEnter (appelé immédiatement après la création des composants)
1128
+ if (route.afterEnter) {
1129
+ route.afterEnter(params, query);
1130
+ }
1131
+
1132
+ // ✅ NOUVEAU : Hook afterLeave de l'ancienne route (après transition complète)
1133
+ if (currentRouteData?.afterLeave) {
1134
+ // Si animation, attendre la fin de la transition
1135
+ if (animate && this.transitionState.isTransitioning) {
1136
+ setTimeout(() => {
1137
+ currentRouteData.afterLeave(oldParams, oldQuery);
1138
+ }, this.transitionState.duration || 300);
1139
+ } else {
1140
+ // Pas d'animation, appeler immédiatement
1141
+ currentRouteData.afterLeave(oldParams, oldQuery);
1142
+ }
1143
+ }
1144
+
1145
+ // ✅ OPTIONNEL : Marquer les composants comme "dirty" pour forcer le rendu
1146
+ this._maxScrollDirty = true;
1063
1147
  }
1064
-
1065
- if (this.isDragging) {
1066
- const deltaY = pos.y - this.lastTouchY;
1067
- this.scrollOffset += deltaY;
1068
- const maxScroll = this.getMaxScroll();
1069
- this.scrollOffset = Math.max(Math.min(this.scrollOffset, 0), -maxScroll);
1070
- this.scrollVelocity = deltaY;
1071
- this.lastTouchY = pos.y;
1072
- } else {
1073
- this.checkComponentsAtPosition(pos.x, pos.y, 'move');
1074
- }
1075
- }
1076
-
1077
- handleTouchEnd(e) {
1078
- e.preventDefault();
1079
- const touch = e.changedTouches[0];
1080
- const pos = this.getTouchPos(touch);
1081
-
1082
- if (!this.isDragging) {
1083
- this.checkComponentsAtPosition(pos.x, pos.y, 'end');
1084
- } else {
1085
- this.isDragging = false;
1086
- }
1087
- }
1088
-
1089
- handleMouseDown(e) {
1090
- this.isDragging = false;
1091
- this.lastTouchY = e.clientY;
1092
- this.checkComponentsAtPosition(e.clientX, e.clientY, 'start');
1093
- }
1094
-
1095
- handleMouseMove(e) {
1096
- if (!this.isDragging) {
1097
- const deltaY = Math.abs(e.clientY - this.lastTouchY);
1098
- if (deltaY > 5) {
1099
- this.isDragging = true;
1100
- }
1148
+
1149
+ /**
1150
+ * Démarre une animation de transition
1151
+ * @private
1152
+ */
1153
+ startTransition(oldComponents, newComponents, type, direction) {
1154
+ this.transitionState = {
1155
+ isTransitioning: true,
1156
+ progress: 0,
1157
+ duration: 300,
1158
+ type,
1159
+ direction,
1160
+ oldComponents: [...oldComponents],
1161
+ newComponents: [...newComponents],
1162
+ startTime: Date.now()
1163
+ };
1101
1164
  }
1102
-
1103
- if (this.isDragging) {
1104
- const deltaY = e.clientY - this.lastTouchY;
1105
- this.scrollOffset += deltaY;
1106
- const maxScroll = this.getMaxScroll();
1107
- this.scrollOffset = Math.max(Math.min(this.scrollOffset, 0), -maxScroll);
1108
- this.scrollVelocity = deltaY;
1109
- this.lastTouchY = e.clientY;
1110
- } else {
1111
- this.checkComponentsAtPosition(e.clientX, e.clientY, 'move');
1112
- }
1113
- }
1114
-
1115
- handleMouseUp(e) {
1116
- if (!this.isDragging) {
1117
- this.checkComponentsAtPosition(e.clientX, e.clientY, 'end');
1118
- } else {
1119
- this.isDragging = false;
1120
- }
1121
- }
1122
-
1123
- getTouchPos(touch) {
1124
- const rect = this.canvas.getBoundingClientRect();
1125
- return {
1126
- x: touch.clientX - rect.left,
1127
- y: touch.clientY - rect.top
1128
- };
1129
- }
1130
-
1131
- checkComponentsAtPosition(x, y, eventType) {
1132
- const isFixedComponent = (comp) =>
1133
- FIXED_COMPONENT_TYPES.has(comp.constructor);
1134
-
1135
- for (let i = this.components.length - 1; i >= 0; i--) {
1136
- const comp = this.components[i];
1137
-
1138
- if (comp.visible) {
1139
- const adjustedY = isFixedComponent(comp) ? y : y - this.scrollOffset;
1140
-
1141
- if (comp instanceof Card && comp.clickableChildren && comp.children && comp.children.length > 0) {
1142
- if (comp.isPointInside(x, adjustedY)) {
1143
- const cardAdjustedY = adjustedY - comp.y - comp.padding;
1144
- const cardAdjustedX = x - comp.x - comp.padding;
1145
-
1146
- for (let j = comp.children.length - 1; j >= 0; j--) {
1147
- const child = comp.children[j];
1148
-
1149
- if (child.visible &&
1150
- cardAdjustedY >= child.y &&
1151
- cardAdjustedY <= child.y + child.height &&
1152
- cardAdjustedX >= child.x &&
1153
- cardAdjustedX <= child.x + child.width) {
1154
-
1155
- const relativeX = cardAdjustedX - child.x;
1156
- const relativeY = cardAdjustedY - child.y;
1157
-
1158
- switch (eventType) {
1159
- case 'start':
1160
- child.pressed = true;
1161
- if (child.onPress) child.onPress?.(relativeX, relativeY);
1162
- break;
1163
-
1164
- case 'move':
1165
- if (!child.hovered) {
1166
- child.hovered = true;
1167
- if (child.onHover) child.onHover();
1168
- }
1169
- if (child.onMove) child.onMove?.(relativeX, relativeY);
1170
- break;
1171
-
1172
- case 'end':
1173
- if (child.pressed) {
1174
- child.pressed = false;
1175
-
1176
- if (child instanceof Input || child instanceof PasswordInput || child instanceof InputTags || child instanceof InputDatalist) {
1177
- for (let other of this.components) {
1178
- if (
1179
- (other instanceof Input ||
1180
- other instanceof PasswordInput ||
1181
- other instanceof InputTags ||
1182
- other instanceof InputDatalist) &&
1183
- other !== child &&
1184
- other.focused
1185
- ) {
1186
- other.focused = false;
1187
- other.cursorVisible = false;
1188
- other.onBlur?.();
1189
- }
1190
- }
1191
-
1192
- child.focused = true;
1193
- child.cursorVisible = true;
1194
- if (child.onFocus) child.onFocus();
1195
- } else if (child.onClick) {
1196
- child.onClick();
1197
- } else if (child.onPress) {
1198
- child.onPress?.(relativeX, relativeY);
1199
- }
1200
- }
1201
- break;
1165
+
1166
+ /**
1167
+ * Met à jour l'animation de transition
1168
+ * @private
1169
+ */
1170
+ updateTransition() {
1171
+ if (!this.transitionState.isTransitioning) return;
1172
+
1173
+ const elapsed = Date.now() - this.transitionState.startTime;
1174
+ this.transitionState.progress = Math.min(elapsed / this.transitionState.duration, 1);
1175
+
1176
+ // Fonction d'easing (ease-in-out)
1177
+ const eased = this.easeInOutCubic(this.transitionState.progress);
1178
+
1179
+ // Appliquer la transformation selon le type
1180
+ this.ctx.save();
1181
+ this.applyTransitionTransform(eased);
1182
+ this.ctx.restore();
1183
+
1184
+ // Terminer la transition
1185
+ if (this.transitionState.progress >= 1) {
1186
+ this.transitionState.isTransitioning = false;
1187
+ this.transitionState.oldComponents = [];
1188
+ }
1189
+ }
1190
+
1191
+ /**
1192
+ * Applique la transformation de transition
1193
+ * @private
1194
+ */
1195
+ applyTransitionTransform(progress) {
1196
+ const {
1197
+ type,
1198
+ direction,
1199
+ oldComponents,
1200
+ newComponents
1201
+ } = this.transitionState;
1202
+ const directionMultiplier = direction === 'forward' ? 1 : -1;
1203
+
1204
+ switch (type) {
1205
+ case 'slide':
1206
+ // Dessiner l'ancienne vue qui sort
1207
+ this.ctx.save();
1208
+ this.ctx.translate(-this.width * progress * directionMultiplier, 0);
1209
+ this.ctx.globalAlpha = 1 - progress * 0.3;
1210
+ for (let comp of oldComponents) {
1211
+ if (comp.visible) comp.draw(this.ctx);
1202
1212
  }
1203
-
1204
- return;
1205
- }
1213
+ this.ctx.restore();
1214
+
1215
+ // Dessiner la nouvelle vue qui entre
1216
+ this.ctx.save();
1217
+ this.ctx.translate(this.width * (1 - progress) * directionMultiplier, 0);
1218
+ for (let comp of newComponents) {
1219
+ if (comp.visible) comp.draw(this.ctx);
1220
+ }
1221
+ this.ctx.restore();
1222
+ break;
1223
+
1224
+ case 'fade':
1225
+ // Dessiner l'ancienne vue qui fade out
1226
+ this.ctx.save();
1227
+ this.ctx.globalAlpha = 1 - progress;
1228
+ for (let comp of oldComponents) {
1229
+ if (comp.visible) comp.draw(this.ctx);
1230
+ }
1231
+ this.ctx.restore();
1232
+
1233
+ // Dessiner la nouvelle vue qui fade in
1234
+ this.ctx.save();
1235
+ this.ctx.globalAlpha = progress;
1236
+ for (let comp of newComponents) {
1237
+ if (comp.visible) comp.draw(this.ctx);
1238
+ }
1239
+ this.ctx.restore();
1240
+ break;
1241
+
1242
+ case 'none':
1243
+ // Pas d'animation, juste afficher la nouvelle vue
1244
+ for (let comp of newComponents) {
1245
+ if (comp.visible) comp.draw(this.ctx);
1246
+ }
1247
+ break;
1248
+ }
1249
+ }
1250
+
1251
+ /**
1252
+ * Fonction d'easing
1253
+ * @private
1254
+ */
1255
+ easeInOutCubic(t) {
1256
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
1257
+ }
1258
+
1259
+ /**
1260
+ * Retour en arrière dans l'historique
1261
+ */
1262
+ goBack() {
1263
+ if (this.historyIndex > 0) {
1264
+ this.historyIndex--;
1265
+ const historyEntry = this.history[this.historyIndex];
1266
+ this.navigateTo(historyEntry.path, {
1267
+ replace: true,
1268
+ animate: true,
1269
+ direction: 'back'
1270
+ });
1271
+ window.history.back();
1272
+ }
1273
+ }
1274
+
1275
+ /**
1276
+ * Avancer dans l'historique
1277
+ */
1278
+ goForward() {
1279
+ if (this.historyIndex < this.history.length - 1) {
1280
+ this.historyIndex++;
1281
+ const historyEntry = this.history[this.historyIndex];
1282
+ this.navigateTo(historyEntry.path, {
1283
+ replace: true,
1284
+ animate: true,
1285
+ direction: 'forward'
1286
+ });
1287
+ window.history.forward();
1288
+ }
1289
+ }
1290
+
1291
+ /**
1292
+ * Obtient les paramètres de la route actuelle
1293
+ * @returns {Object}
1294
+ */
1295
+ getParams() {
1296
+ return {
1297
+ ...this.currentParams
1298
+ };
1299
+ }
1300
+
1301
+ /**
1302
+ * Obtient la query string de la route actuelle
1303
+ * @returns {Object}
1304
+ */
1305
+ getQuery() {
1306
+ return {
1307
+ ...this.currentQuery
1308
+ };
1309
+ }
1310
+
1311
+ /**
1312
+ * Obtient un paramètre spécifique
1313
+ * @param {string} name
1314
+ * @returns {string|undefined}
1315
+ */
1316
+ getParam(name) {
1317
+ return this.currentParams[name];
1318
+ }
1319
+
1320
+ /**
1321
+ * Obtient un paramètre de query spécifique
1322
+ * @param {string} name
1323
+ * @returns {string|undefined}
1324
+ */
1325
+ getQueryParam(name) {
1326
+ return this.currentQuery[name];
1327
+ }
1328
+
1329
+ // ===== FIN DES MÉTHODES DE ROUTING =====
1330
+
1331
+ handleTouchStart(e) {
1332
+ e.preventDefault();
1333
+ this.isDragging = false;
1334
+ const touch = e.touches[0];
1335
+ const pos = this.getTouchPos(touch);
1336
+ this.lastTouchY = pos.y;
1337
+ this.checkComponentsAtPosition(pos.x, pos.y, 'start');
1338
+ }
1339
+
1340
+ handleTouchMove(e) {
1341
+ e.preventDefault();
1342
+ const touch = e.touches[0];
1343
+ const pos = this.getTouchPos(touch);
1344
+
1345
+ if (!this.isDragging) {
1346
+ const deltaY = Math.abs(pos.y - this.lastTouchY);
1347
+ if (deltaY > 5) {
1348
+ this.isDragging = true;
1206
1349
  }
1207
- }
1208
1350
  }
1209
-
1210
- if (comp.isPointInside(x, adjustedY)) {
1211
- switch (eventType) {
1212
- case 'start':
1213
- comp.pressed = true;
1214
- if (comp.onPress) comp.onPress(x, adjustedY);
1215
- break;
1216
-
1217
- case 'move':
1218
- if (!comp.hovered) {
1219
- comp.hovered = true;
1220
- if (comp.onHover) comp.onHover();
1221
- }
1222
- if (comp.onMove) comp.onMove(x, adjustedY);
1223
- break;
1224
-
1225
- case 'end':
1226
- if (comp.pressed) {
1227
- comp.pressed = false;
1228
-
1229
- if (comp instanceof Input || comp instanceof PasswordInput || comp instanceof InputTags || comp instanceof InputDatalist) {
1230
- for (let other of this.components) {
1231
- if (
1232
- (other instanceof Input ||
1233
- other instanceof PasswordInput ||
1234
- other instanceof InputTags ||
1235
- other instanceof InputDatalist) &&
1236
- other !== comp &&
1237
- other.focused
1238
- ) {
1239
- other.focused = false;
1240
- other.cursorVisible = false;
1241
- other.onBlur?.();
1242
- }
1243
- }
1244
-
1245
- comp.focused = true;
1246
- comp.cursorVisible = true;
1247
- if (comp.onFocus) comp.onFocus();
1248
- } else if (comp.onClick) {
1249
- comp.onClick();
1250
- } else if (comp.onPress) {
1251
- comp.onPress(x, adjustedY);
1252
- }
1253
- }
1254
- break;
1255
- }
1256
-
1257
- return;
1351
+
1352
+ if (this.isDragging) {
1353
+ const deltaY = pos.y - this.lastTouchY;
1354
+ this.scrollOffset += deltaY;
1355
+ const maxScroll = this.getMaxScroll();
1356
+ this.scrollOffset = Math.max(Math.min(this.scrollOffset, 0), -maxScroll);
1357
+ this.scrollVelocity = deltaY;
1358
+ this.lastTouchY = pos.y;
1258
1359
  } else {
1259
- comp.hovered = false;
1360
+ this.checkComponentsAtPosition(pos.x, pos.y, 'move');
1260
1361
  }
1261
- }
1262
1362
  }
1263
- }
1264
-
1265
- getMaxScroll() {
1266
- if (!this._maxScrollDirty) return this._cachedMaxScroll;
1267
-
1268
- let maxY = 0;
1269
- for (const comp of this.components) {
1270
- if (this.isFixedComponent(comp) || !comp.visible) continue;
1271
- const bottom = comp.y + comp.height;
1272
- if (bottom > maxY) maxY = bottom;
1273
- }
1274
-
1275
- this._cachedMaxScroll = Math.max(0, maxY - this.height + 50);
1276
- this._maxScrollDirty = false;
1277
- return this._cachedMaxScroll;
1278
- }
1279
-
1280
- /*getMaxScroll() {
1281
- let maxY = 0;
1282
- for (let comp of this.components) {
1283
- if (!this.isFixedComponent(comp)) {
1284
- maxY = Math.max(maxY, comp.y + comp.height);
1285
- }
1363
+
1364
+ handleTouchEnd(e) {
1365
+ e.preventDefault();
1366
+ const touch = e.changedTouches[0];
1367
+ const pos = this.getTouchPos(touch);
1368
+
1369
+ if (!this.isDragging) {
1370
+ this.checkComponentsAtPosition(pos.x, pos.y, 'end');
1371
+ } else {
1372
+ this.isDragging = false;
1373
+ }
1286
1374
  }
1287
- return Math.max(0, maxY - this.height + 50);
1288
- }*/
1289
1375
 
1290
- handleResize() {
1291
- if (this.resizeTimeout) clearTimeout(this.resizeTimeout); // ✅ AJOUTER
1292
-
1293
- this.resizeTimeout = setTimeout(() => { // ✅ AJOUTER
1294
- if (!this.useWebGL) {
1295
- this.width = window.innerWidth;
1296
- this.height = window.innerHeight;
1297
- this.setupCanvas();
1298
-
1299
- for (const comp of this.components) {
1300
- if (comp._resize) {
1301
- comp._resize(this.width, this.height);
1302
- }
1376
+ handleMouseDown(e) {
1377
+ this.isDragging = false;
1378
+ this.lastTouchY = e.clientY;
1379
+ this.checkComponentsAtPosition(e.clientX, e.clientY, 'start');
1380
+ }
1381
+
1382
+ handleMouseMove(e) {
1383
+ if (!this.isDragging) {
1384
+ const deltaY = Math.abs(e.clientY - this.lastTouchY);
1385
+ if (deltaY > 5) {
1386
+ this.isDragging = true;
1387
+ }
1303
1388
  }
1304
- this._maxScrollDirty = true; // ✅ AJOUTER
1305
- }
1306
- }, 150); // AJOUTER (throttle 150ms)
1307
- }
1308
-
1309
- add(component) {
1310
- this.components.push(component);
1311
- component._mount();
1312
- this._maxScrollDirty = true; // ✅ AJOUTER CETTE LIGNE
1313
- return component;
1314
- }
1315
-
1316
- remove(component) {
1317
- const index = this.components.indexOf(component);
1318
- if (index > -1) {
1319
- component._unmount();
1320
- this.components.splice(index, 1);
1321
- this._maxScrollDirty = true; // ✅ AJOUTER CETTE LIGNE
1322
- }
1323
- }
1324
-
1325
- markComponentDirty(component) {
1326
- if (this.optimizationEnabled) {
1327
- this.dirtyComponents.add(component);
1328
- }
1329
- }
1330
-
1331
- enableOptimization() {
1332
- this.optimizationEnabled = true;
1333
- }
1334
-
1335
- /**
1336
- * Dessine un petit triangle rouge pour indiquer overflow (style Flutter)
1337
- */
1338
- drawOverflowIndicators() {
1339
- const ctx = this.ctx;
1340
-
1341
- // Pour chaque composant
1342
- for (let comp of this.components) {
1343
- if (!comp.visible) continue;
1344
-
1345
- // Position réelle à l'écran
1346
- const isFixed = this.isFixedComponent(comp);
1347
- const screenY = isFixed ? comp.y : comp.y + this.scrollOffset;
1348
- const screenX = comp.x;
1349
-
1350
- // Vérifier si le composant TEXT a une largeur/hauteur incorrecte
1351
- let actualWidth = comp.width;
1352
- let actualHeight = comp.height;
1353
-
1354
- // Si c'est un Text, vérifier la taille réelle du texte
1355
- if (comp instanceof Text && comp.text && ctx.measureText) {
1356
- try {
1357
- // Sauvegarder le style actuel
1358
- ctx.save();
1359
-
1360
- // Appliquer le style du texte
1361
- if (comp.fontSize) {
1362
- ctx.font = `${comp.fontSize}px ${comp.fontFamily || 'Arial'}`;
1363
- }
1364
-
1365
- // Mesurer la taille réelle
1366
- const metrics = ctx.measureText(comp.text);
1367
- actualWidth = metrics.width + (comp.padding || 0) * 2;
1368
- actualHeight = (comp.fontSize || 16) + (comp.padding || 0) * 2;
1369
-
1370
- ctx.restore();
1371
- } catch (e) {
1372
- // En cas d'erreur, garder les dimensions par défaut
1389
+
1390
+ if (this.isDragging) {
1391
+ const deltaY = e.clientY - this.lastTouchY;
1392
+ this.scrollOffset += deltaY;
1393
+ const maxScroll = this.getMaxScroll();
1394
+ this.scrollOffset = Math.max(Math.min(this.scrollOffset, 0), -maxScroll);
1395
+ this.scrollVelocity = deltaY;
1396
+ this.lastTouchY = e.clientY;
1397
+ } else {
1398
+ this.checkComponentsAtPosition(e.clientX, e.clientY, 'move');
1373
1399
  }
1374
- }
1375
-
1376
- // Calculer les limites RÉELLES du composant
1377
- const compLeft = screenX;
1378
- const compRight = screenX + actualWidth;
1379
- const compTop = screenY;
1380
- const compBottom = screenY + actualHeight;
1381
-
1382
- // Vérifier les débordements avec les dimensions RÉELLES
1383
- const overflow = {
1384
- left: compLeft < 0,
1385
- right: compRight > this.width,
1386
- top: compTop < 0,
1387
- bottom: compBottom > this.height
1388
- };
1389
-
1390
- // Si aucun débordement, passer au suivant
1391
- if (!overflow.left && !overflow.right && !overflow.top && !overflow.bottom) {
1392
- continue;
1393
- }
1394
-
1395
- // DEBUG: Afficher les infos du composant
1396
- if (this.debbug) {
1397
- console.table({
1398
- type: comp.constructor?.name,
1399
- x: comp.x,
1400
- y: comp.y,
1401
- declaredSize: `${comp.width}x${comp.height}`,
1402
- actualSize: `${actualWidth}x${actualHeight}`,
1403
- screenPos: `(${screenX}, ${screenY})`,
1404
- overflow
1405
- });
1406
- }
1407
-
1408
- // Dessiner les indicateurs
1409
- ctx.save();
1410
-
1411
- // 1. Bordures rouges sur les parties qui débordent
1412
- ctx.strokeStyle = 'red';
1413
- ctx.lineWidth = 2;
1414
- ctx.fillStyle = 'rgba(255, 0, 0, 0.2)';
1415
-
1416
- // Gauche
1417
- if (overflow.left) {
1418
- const overflowWidth = Math.min(actualWidth, -compLeft);
1419
- ctx.fillRect(compLeft, compTop, overflowWidth, actualHeight);
1420
- ctx.strokeRect(compLeft, compTop, overflowWidth, actualHeight);
1421
- }
1422
-
1423
- // Droite
1424
- if (overflow.right) {
1425
- const overflowStart = Math.max(0, this.width - compLeft);
1426
- const overflowWidth = Math.min(actualWidth, compRight - this.width);
1427
- ctx.fillRect(this.width - overflowWidth, compTop, overflowWidth, actualHeight);
1428
- ctx.strokeRect(this.width - overflowWidth, compTop, overflowWidth, actualHeight);
1429
- }
1430
-
1431
- // Haut
1432
- if (overflow.top) {
1433
- const overflowHeight = Math.min(actualHeight, -compTop);
1434
- ctx.fillRect(compLeft, compTop, actualWidth, overflowHeight);
1435
- ctx.strokeRect(compLeft, compTop, actualWidth, overflowHeight);
1436
- }
1437
-
1438
- // Bas
1439
- if (overflow.bottom) {
1440
- const overflowStart = Math.max(0, this.height - compTop);
1441
- const overflowHeight = Math.min(actualHeight, compBottom - this.height);
1442
- ctx.fillRect(compLeft, this.height - overflowHeight, actualWidth, overflowHeight);
1443
- ctx.strokeRect(compLeft, this.height - overflowHeight, actualWidth, overflowHeight);
1444
- }
1445
-
1446
- // 2. Points rouges aux coins
1447
- ctx.fillStyle = 'red';
1448
- const markerSize = 6;
1449
-
1450
- // Coin supérieur gauche
1451
- if (overflow.left || overflow.top) {
1452
- ctx.fillRect(compLeft, compTop, markerSize, markerSize);
1453
- }
1454
-
1455
- // Coin supérieur droit
1456
- if (overflow.right || overflow.top) {
1457
- ctx.fillRect(compRight - markerSize, compTop, markerSize, markerSize);
1458
- }
1459
-
1460
- // Coin inférieur gauche
1461
- if (overflow.left || overflow.bottom) {
1462
- ctx.fillRect(compLeft, compBottom - markerSize, markerSize, markerSize);
1463
- }
1464
-
1465
- // Coin inférieur droit
1466
- if (overflow.right || overflow.bottom) {
1467
- ctx.fillRect(compRight - markerSize, compBottom - markerSize, markerSize, markerSize);
1468
- }
1469
-
1470
- // 3. Texte d'information (optionnel)
1471
- if (this.debbug && comp.text) {
1472
- ctx.fillStyle = 'red';
1473
- ctx.font = '10px monospace';
1474
- ctx.textAlign = 'left';
1475
-
1476
- const overflowText = [];
1477
- if (overflow.left) overflowText.push('←');
1478
- if (overflow.right) overflowText.push('');
1479
- if (overflow.top) overflowText.push('↑');
1480
- if (overflow.bottom) overflowText.push('↓');
1481
-
1482
- if (overflowText.length > 0) {
1483
- ctx.fillText(
1484
- `"${comp.text.substring(0, 10)}${comp.text.length > 10 ? '...' : ''}" ${overflowText.join('')}`,
1485
- compLeft + 5,
1486
- compTop - 5
1487
- );
1400
+ }
1401
+
1402
+ handleMouseUp(e) {
1403
+ if (!this.isDragging) {
1404
+ this.checkComponentsAtPosition(e.clientX, e.clientY, 'end');
1405
+ } else {
1406
+ this.isDragging = false;
1407
+ }
1408
+ }
1409
+
1410
+ getTouchPos(touch) {
1411
+ const rect = this.canvas.getBoundingClientRect();
1412
+ return {
1413
+ x: touch.clientX - rect.left,
1414
+ y: touch.clientY - rect.top
1415
+ };
1416
+ }
1417
+
1418
+ checkComponentsAtPosition(x, y, eventType) {
1419
+ const isFixedComponent = (comp) =>
1420
+ FIXED_COMPONENT_TYPES.has(comp.constructor);
1421
+
1422
+ for (let i = this.components.length - 1; i >= 0; i--) {
1423
+ const comp = this.components[i];
1424
+
1425
+ if (comp.visible) {
1426
+ const adjustedY = isFixedComponent(comp) ? y : y - this.scrollOffset;
1427
+
1428
+ if (comp instanceof Card && comp.clickableChildren && comp.children && comp.children.length > 0) {
1429
+ if (comp.isPointInside(x, adjustedY)) {
1430
+ const cardAdjustedY = adjustedY - comp.y - comp.padding;
1431
+ const cardAdjustedX = x - comp.x - comp.padding;
1432
+
1433
+ for (let j = comp.children.length - 1; j >= 0; j--) {
1434
+ const child = comp.children[j];
1435
+
1436
+ if (child.visible &&
1437
+ cardAdjustedY >= child.y &&
1438
+ cardAdjustedY <= child.y + child.height &&
1439
+ cardAdjustedX >= child.x &&
1440
+ cardAdjustedX <= child.x + child.width) {
1441
+
1442
+ const relativeX = cardAdjustedX - child.x;
1443
+ const relativeY = cardAdjustedY - child.y;
1444
+
1445
+ switch (eventType) {
1446
+ case 'start':
1447
+ child.pressed = true;
1448
+ if (child.onPress) child.onPress?.(relativeX, relativeY);
1449
+ break;
1450
+
1451
+ case 'move':
1452
+ if (!child.hovered) {
1453
+ child.hovered = true;
1454
+ if (child.onHover) child.onHover();
1455
+ }
1456
+ if (child.onMove) child.onMove?.(relativeX, relativeY);
1457
+ break;
1458
+
1459
+ case 'end':
1460
+ if (child.pressed) {
1461
+ child.pressed = false;
1462
+
1463
+ if (child instanceof Input || child instanceof PasswordInput || child instanceof InputTags || child instanceof InputDatalist) {
1464
+ for (let other of this.components) {
1465
+ if (
1466
+ (other instanceof Input ||
1467
+ other instanceof PasswordInput ||
1468
+ other instanceof InputTags ||
1469
+ other instanceof InputDatalist) &&
1470
+ other !== child &&
1471
+ other.focused
1472
+ ) {
1473
+ other.focused = false;
1474
+ other.cursorVisible = false;
1475
+ other.onBlur?.();
1476
+ }
1477
+ }
1478
+
1479
+ child.focused = true;
1480
+ child.cursorVisible = true;
1481
+ if (child.onFocus) child.onFocus();
1482
+ } else if (child.onClick) {
1483
+ child.onClick();
1484
+ } else if (child.onPress) {
1485
+ child.onPress?.(relativeX, relativeY);
1486
+ }
1487
+ }
1488
+ break;
1489
+ }
1490
+
1491
+ return;
1492
+ }
1493
+ }
1494
+ }
1495
+ }
1496
+
1497
+ if (comp.isPointInside(x, adjustedY)) {
1498
+ switch (eventType) {
1499
+ case 'start':
1500
+ comp.pressed = true;
1501
+ if (comp.onPress) comp.onPress(x, adjustedY);
1502
+ break;
1503
+
1504
+ case 'move':
1505
+ if (!comp.hovered) {
1506
+ comp.hovered = true;
1507
+ if (comp.onHover) comp.onHover();
1508
+ }
1509
+ if (comp.onMove) comp.onMove(x, adjustedY);
1510
+ break;
1511
+
1512
+ case 'end':
1513
+ if (comp.pressed) {
1514
+ comp.pressed = false;
1515
+
1516
+ if (comp instanceof Input || comp instanceof PasswordInput || comp instanceof InputTags || comp instanceof InputDatalist) {
1517
+ for (let other of this.components) {
1518
+ if (
1519
+ (other instanceof Input ||
1520
+ other instanceof PasswordInput ||
1521
+ other instanceof InputTags ||
1522
+ other instanceof InputDatalist) &&
1523
+ other !== comp &&
1524
+ other.focused
1525
+ ) {
1526
+ other.focused = false;
1527
+ other.cursorVisible = false;
1528
+ other.onBlur?.();
1529
+ }
1530
+ }
1531
+
1532
+ comp.focused = true;
1533
+ comp.cursorVisible = true;
1534
+ if (comp.onFocus) comp.onFocus();
1535
+ } else if (comp.onClick) {
1536
+ comp.onClick();
1537
+ } else if (comp.onPress) {
1538
+ comp.onPress(x, adjustedY);
1539
+ }
1540
+ }
1541
+ break;
1542
+ }
1543
+
1544
+ return;
1545
+ } else {
1546
+ comp.hovered = false;
1547
+ }
1548
+ }
1549
+ }
1550
+ }
1551
+
1552
+ getMaxScroll() {
1553
+ if (!this._maxScrollDirty) return this._cachedMaxScroll;
1554
+
1555
+ let maxY = 0;
1556
+ for (const comp of this.components) {
1557
+ if (this.isFixedComponent(comp) || !comp.visible) continue;
1558
+ const bottom = comp.y + comp.height;
1559
+ if (bottom > maxY) maxY = bottom;
1560
+ }
1561
+
1562
+ this._cachedMaxScroll = Math.max(0, maxY - this.height + 50);
1563
+ this._maxScrollDirty = false;
1564
+ return this._cachedMaxScroll;
1565
+ }
1566
+
1567
+ /*getMaxScroll() {
1568
+ let maxY = 0;
1569
+ for (let comp of this.components) {
1570
+ if (!this.isFixedComponent(comp)) {
1571
+ maxY = Math.max(maxY, comp.y + comp.height);
1488
1572
  }
1489
1573
  }
1490
-
1491
- ctx.restore();
1492
- }
1493
- }
1494
-
1495
- startRenderLoop() {
1496
- const render = () => {
1497
- // 1️⃣ Scroll inertia
1498
- if (Math.abs(this.scrollVelocity) > 0.1 && !this.isDragging) {
1499
- this.scrollOffset += this.scrollVelocity;
1500
- this.scrollOffset = Math.max(Math.min(this.scrollOffset, 0), -this.getMaxScroll());
1501
- this.scrollVelocity *= this.scrollFriction;
1502
- } else {
1503
- this.scrollVelocity = 0;
1504
- }
1574
+ return Math.max(0, maxY - this.height + 50);
1575
+ }*/
1505
1576
 
1506
- // 2️⃣ Clear canvas
1507
- this.ctx.clearRect(0, 0, this.width, this.height);
1577
+ handleResize() {
1578
+ if (this.resizeTimeout) clearTimeout(this.resizeTimeout); // ✅ AJOUTER
1508
1579
 
1509
- // 3️⃣ Transition handling
1510
- if (this.transitionState.isTransitioning) {
1511
- this.updateTransition();
1512
- } else if (this.optimizationEnabled && this.dirtyComponents.size > 0) {
1513
- // Dirty components redraw
1514
- for (let comp of this.dirtyComponents) {
1515
- if (comp.visible) {
1516
- const isFixed = this.isFixedComponent(comp);
1517
- const y = isFixed ? comp.y : comp.y + this.scrollOffset;
1580
+ this.resizeTimeout = setTimeout(() => { // AJOUTER
1581
+ if (!this.useWebGL) {
1582
+ this.width = window.innerWidth;
1583
+ this.height = window.innerHeight;
1584
+ this.setupCanvas();
1518
1585
 
1519
- this.ctx.clearRect(comp.x - 2, y - 2, comp.width + 4, comp.height + 4);
1586
+ for (const comp of this.components) {
1587
+ if (comp._resize) {
1588
+ comp._resize(this.width, this.height);
1589
+ }
1590
+ }
1591
+ this._maxScrollDirty = true; // ✅ AJOUTER
1592
+ }
1593
+ }, 150); // ✅ AJOUTER (throttle 150ms)
1594
+ }
1520
1595
 
1521
- this.ctx.save();
1522
- if (!isFixed) this.ctx.translate(0, this.scrollOffset);
1523
- comp.draw(this.ctx);
1524
- this.ctx.restore();
1596
+ add(component) {
1597
+ this.components.push(component);
1598
+ component._mount();
1599
+ this._maxScrollDirty = true; // ✅ AJOUTER CETTE LIGNE
1600
+ return component;
1601
+ }
1525
1602
 
1526
- // Overflow indicator style Flutter
1527
- const overflow = comp.getOverflow?.();
1528
- if (comp.markClean) comp.markClean();
1529
- }
1603
+ remove(component) {
1604
+ const index = this.components.indexOf(component);
1605
+ if (index > -1) {
1606
+ component._unmount();
1607
+ this.components.splice(index, 1);
1608
+ this._maxScrollDirty = true; // ✅ AJOUTER CETTE LIGNE
1530
1609
  }
1531
- this.dirtyComponents.clear();
1532
- } else {
1533
- // Full redraw
1534
- const scrollableComponents = [];
1535
- const fixedComponents = [];
1610
+ }
1536
1611
 
1537
- for (let comp of this.components) {
1538
- if (this.isFixedComponent(comp)) fixedComponents.push(comp);
1539
- else scrollableComponents.push(comp);
1612
+ markComponentDirty(component) {
1613
+ if (this.optimizationEnabled) {
1614
+ this.dirtyComponents.add(component);
1540
1615
  }
1616
+ }
1617
+
1618
+ enableOptimization() {
1619
+ this.optimizationEnabled = true;
1620
+ }
1621
+
1622
+ /**
1623
+ * Dessine un petit triangle rouge pour indiquer overflow (style Flutter)
1624
+ */
1625
+ drawOverflowIndicators() {
1626
+ const ctx = this.ctx;
1627
+
1628
+ // Pour chaque composant
1629
+ for (let comp of this.components) {
1630
+ if (!comp.visible) continue;
1631
+
1632
+ // Position réelle à l'écran
1633
+ const isFixed = this.isFixedComponent(comp);
1634
+ const screenY = isFixed ? comp.y : comp.y + this.scrollOffset;
1635
+ const screenX = comp.x;
1636
+
1637
+ // Vérifier si le composant TEXT a une largeur/hauteur incorrecte
1638
+ let actualWidth = comp.width;
1639
+ let actualHeight = comp.height;
1640
+
1641
+ // Si c'est un Text, vérifier la taille réelle du texte
1642
+ if (comp instanceof Text && comp.text && ctx.measureText) {
1643
+ try {
1644
+ // Sauvegarder le style actuel
1645
+ ctx.save();
1646
+
1647
+ // Appliquer le style du texte
1648
+ if (comp.fontSize) {
1649
+ ctx.font = `${comp.fontSize}px ${comp.fontFamily || 'Arial'}`;
1650
+ }
1651
+
1652
+ // Mesurer la taille réelle
1653
+ const metrics = ctx.measureText(comp.text);
1654
+ actualWidth = metrics.width + (comp.padding || 0) * 2;
1655
+ actualHeight = (comp.fontSize || 16) + (comp.padding || 0) * 2;
1656
+
1657
+ ctx.restore();
1658
+ } catch (e) {
1659
+ // En cas d'erreur, garder les dimensions par défaut
1660
+ }
1661
+ }
1662
+
1663
+ // Calculer les limites RÉELLES du composant
1664
+ const compLeft = screenX;
1665
+ const compRight = screenX + actualWidth;
1666
+ const compTop = screenY;
1667
+ const compBottom = screenY + actualHeight;
1668
+
1669
+ // Vérifier les débordements avec les dimensions RÉELLES
1670
+ const overflow = {
1671
+ left: compLeft < 0,
1672
+ right: compRight > this.width,
1673
+ top: compTop < 0,
1674
+ bottom: compBottom > this.height
1675
+ };
1676
+
1677
+ // Si aucun débordement, passer au suivant
1678
+ if (!overflow.left && !overflow.right && !overflow.top && !overflow.bottom) {
1679
+ continue;
1680
+ }
1681
+
1682
+ // DEBUG: Afficher les infos du composant
1683
+ if (this.debbug) {
1684
+ console.table({
1685
+ type: comp.constructor?.name,
1686
+ x: comp.x,
1687
+ y: comp.y,
1688
+ declaredSize: `${comp.width}x${comp.height}`,
1689
+ actualSize: `${actualWidth}x${actualHeight}`,
1690
+ screenPos: `(${screenX}, ${screenY})`,
1691
+ overflow
1692
+ });
1693
+ }
1694
+
1695
+ // Dessiner les indicateurs
1696
+ ctx.save();
1697
+
1698
+ // 1. Bordures rouges sur les parties qui débordent
1699
+ ctx.strokeStyle = 'red';
1700
+ ctx.lineWidth = 2;
1701
+ ctx.fillStyle = 'rgba(255, 0, 0, 0.2)';
1702
+
1703
+ // Gauche
1704
+ if (overflow.left) {
1705
+ const overflowWidth = Math.min(actualWidth, -compLeft);
1706
+ ctx.fillRect(compLeft, compTop, overflowWidth, actualHeight);
1707
+ ctx.strokeRect(compLeft, compTop, overflowWidth, actualHeight);
1708
+ }
1709
+
1710
+ // Droite
1711
+ if (overflow.right) {
1712
+ const overflowStart = Math.max(0, this.width - compLeft);
1713
+ const overflowWidth = Math.min(actualWidth, compRight - this.width);
1714
+ ctx.fillRect(this.width - overflowWidth, compTop, overflowWidth, actualHeight);
1715
+ ctx.strokeRect(this.width - overflowWidth, compTop, overflowWidth, actualHeight);
1716
+ }
1717
+
1718
+ // Haut
1719
+ if (overflow.top) {
1720
+ const overflowHeight = Math.min(actualHeight, -compTop);
1721
+ ctx.fillRect(compLeft, compTop, actualWidth, overflowHeight);
1722
+ ctx.strokeRect(compLeft, compTop, actualWidth, overflowHeight);
1723
+ }
1541
1724
 
1542
- // Scrollable
1543
- this.ctx.save();
1544
- this.ctx.translate(0, this.scrollOffset);
1545
- for (let comp of scrollableComponents) {
1546
- if (comp.visible) {
1547
- // Viewport culling : ne dessiner que ce qui est visible
1548
- const screenY = comp.y + this.scrollOffset;
1549
- const isInViewport = screenY + comp.height >= -100 && screenY <= this.height + 100;
1550
-
1551
- if (isInViewport) {
1552
- comp.draw(this.ctx);
1553
- }
1554
- }
1555
- }
1556
- this.ctx.restore();
1557
-
1558
- // Fixed
1559
- for (let comp of fixedComponents) {
1560
- if (comp.visible) {
1561
- comp.draw(this.ctx);
1562
- }
1725
+ // Bas
1726
+ if (overflow.bottom) {
1727
+ const overflowStart = Math.max(0, this.height - compTop);
1728
+ const overflowHeight = Math.min(actualHeight, compBottom - this.height);
1729
+ ctx.fillRect(compLeft, this.height - overflowHeight, actualWidth, overflowHeight);
1730
+ ctx.strokeRect(compLeft, this.height - overflowHeight, actualWidth, overflowHeight);
1731
+ }
1732
+
1733
+ // 2. Points rouges aux coins
1734
+ ctx.fillStyle = 'red';
1735
+ const markerSize = 6;
1736
+
1737
+ // Coin supérieur gauche
1738
+ if (overflow.left || overflow.top) {
1739
+ ctx.fillRect(compLeft, compTop, markerSize, markerSize);
1740
+ }
1741
+
1742
+ // Coin supérieur droit
1743
+ if (overflow.right || overflow.top) {
1744
+ ctx.fillRect(compRight - markerSize, compTop, markerSize, markerSize);
1745
+ }
1746
+
1747
+ // Coin inférieur gauche
1748
+ if (overflow.left || overflow.bottom) {
1749
+ ctx.fillRect(compLeft, compBottom - markerSize, markerSize, markerSize);
1750
+ }
1751
+
1752
+ // Coin inférieur droit
1753
+ if (overflow.right || overflow.bottom) {
1754
+ ctx.fillRect(compRight - markerSize, compBottom - markerSize, markerSize, markerSize);
1755
+ }
1756
+
1757
+ // 3. Texte d'information (optionnel)
1758
+ if (this.debbug && comp.text) {
1759
+ ctx.fillStyle = 'red';
1760
+ ctx.font = '10px monospace';
1761
+ ctx.textAlign = 'left';
1762
+
1763
+ const overflowText = [];
1764
+ if (overflow.left) overflowText.push('←');
1765
+ if (overflow.right) overflowText.push('→');
1766
+ if (overflow.top) overflowText.push('↑');
1767
+ if (overflow.bottom) overflowText.push('↓');
1768
+
1769
+ if (overflowText.length > 0) {
1770
+ ctx.fillText(
1771
+ `"${comp.text.substring(0, 10)}${comp.text.length > 10 ? '...' : ''}" ${overflowText.join('')}`,
1772
+ compLeft + 5,
1773
+ compTop - 5
1774
+ );
1775
+ }
1776
+ }
1777
+
1778
+ ctx.restore();
1563
1779
  }
1564
- }
1780
+ }
1565
1781
 
1566
- // 4️⃣ FPS
1567
- this._frames++;
1568
- const now = performance.now();
1569
- if (now - this._lastFpsTime >= 1000) {
1570
- this.fps = this._frames;
1571
- this._frames = 0;
1572
- this._lastFpsTime = now;
1573
- }
1782
+ startRenderLoop() {
1783
+ const render = () => {
1784
+ if (!this._splashFinished) {
1785
+ requestAnimationFrame(render);
1786
+ return;
1787
+ }
1788
+ // 1️⃣ Scroll inertia
1789
+ if (Math.abs(this.scrollVelocity) > 0.1 && !this.isDragging) {
1790
+ this.scrollOffset += this.scrollVelocity;
1791
+ this.scrollOffset = Math.max(Math.min(this.scrollOffset, 0), -this.getMaxScroll());
1792
+ this.scrollVelocity *= this.scrollFriction;
1793
+ } else {
1794
+ this.scrollVelocity = 0;
1795
+ }
1574
1796
 
1575
- if (this.showFps) {
1576
- this.ctx.save();
1577
- this.ctx.fillStyle = 'lime';
1578
- this.ctx.font = '16px monospace';
1579
- this.ctx.fillText(`FPS: ${this.fps}`, 10, 20);
1580
- this.ctx.restore();
1581
- }
1582
-
1583
- if(this.debbug) {
1584
- this.drawOverflowIndicators();
1585
- }
1586
-
1587
- requestAnimationFrame(render);
1588
- };
1589
-
1590
- render();
1591
- }
1592
-
1593
- isFixedComponent(comp) {
1594
- return FIXED_COMPONENT_TYPES.has(comp.constructor);
1595
- }
1596
-
1597
- showToast(message, duration = 3000) {
1598
- const toast = new Toast(this, {
1599
- text: message,
1600
- duration: duration,
1601
- x: this.width / 2,
1602
- y: this.height - 100
1603
- });
1604
- this.add(toast);
1605
- toast.show();
1606
- }
1607
- }
1797
+ // 2️⃣ Clear canvas
1798
+ this.ctx.clearRect(0, 0, this.width, this.height);
1799
+
1800
+ // 3️⃣ Transition handling
1801
+ if (this.transitionState.isTransitioning) {
1802
+ this.updateTransition();
1803
+ } else if (this.optimizationEnabled && this.dirtyComponents.size > 0) {
1804
+ // Dirty components redraw
1805
+ for (let comp of this.dirtyComponents) {
1806
+ if (comp.visible) {
1807
+ const isFixed = this.isFixedComponent(comp);
1808
+ const y = isFixed ? comp.y : comp.y + this.scrollOffset;
1809
+
1810
+ this.ctx.clearRect(comp.x - 2, y - 2, comp.width + 4, comp.height + 4);
1811
+
1812
+ this.ctx.save();
1813
+ if (!isFixed) this.ctx.translate(0, this.scrollOffset);
1814
+ comp.draw(this.ctx);
1815
+ this.ctx.restore();
1816
+
1817
+ // Overflow indicator style Flutter
1818
+ const overflow = comp.getOverflow?.();
1819
+ if (comp.markClean) comp.markClean();
1820
+ }
1821
+ }
1822
+ this.dirtyComponents.clear();
1823
+ } else {
1824
+ // Full redraw
1825
+ const scrollableComponents = [];
1826
+ const fixedComponents = [];
1827
+
1828
+ for (let comp of this.components) {
1829
+ if (this.isFixedComponent(comp)) fixedComponents.push(comp);
1830
+ else scrollableComponents.push(comp);
1831
+ }
1608
1832
 
1609
- export default CanvasFramework;
1833
+ // Scrollable
1834
+ this.ctx.save();
1835
+ this.ctx.translate(0, this.scrollOffset);
1836
+ for (let comp of scrollableComponents) {
1837
+ if (comp.visible) {
1838
+ // ✅ Viewport culling : ne dessiner que ce qui est visible
1839
+ const screenY = comp.y + this.scrollOffset;
1840
+ const isInViewport = screenY + comp.height >= -100 && screenY <= this.height + 100;
1841
+
1842
+ if (isInViewport) {
1843
+ comp.draw(this.ctx);
1844
+ }
1845
+ }
1846
+ }
1847
+ this.ctx.restore();
1848
+
1849
+ // Fixed
1850
+ for (let comp of fixedComponents) {
1851
+ if (comp.visible) {
1852
+ comp.draw(this.ctx);
1853
+ }
1854
+ }
1855
+ }
1856
+
1857
+ // 4️⃣ FPS
1858
+ this._frames++;
1859
+ const now = performance.now();
1860
+ if (now - this._lastFpsTime >= 1000) {
1861
+ this.fps = this._frames;
1862
+ this._frames = 0;
1863
+ this._lastFpsTime = now;
1864
+ }
1865
+
1866
+ if (this.showFps) {
1867
+ this.ctx.save();
1868
+ this.ctx.fillStyle = 'lime';
1869
+ this.ctx.font = '16px monospace';
1870
+ this.ctx.fillText(`FPS: ${this.fps}`, 10, 20);
1871
+ this.ctx.restore();
1872
+ }
1873
+
1874
+ if (this.debbug) {
1875
+ this.drawOverflowIndicators();
1876
+ }
1877
+
1878
+ // ✅ AJOUTER: Marquer le premier rendu
1879
+ if (!this._firstRenderDone && this.components.length > 0) {
1880
+ this._markFirstRender();
1881
+ }
1882
+
1883
+ requestAnimationFrame(render);
1884
+ };
1885
+
1886
+ render();
1887
+ }
1888
+
1889
+ // ✅ AJOUTER: Afficher les métriques à l'écran
1890
+ displayMetrics() {
1891
+ const metrics = this.metrics;
1892
+
1893
+ console.table({
1894
+ '⚙️ Initialisation Framework': `${metrics.initTime.toFixed(2)}ms`,
1895
+ '🎨 Premier Rendu': `${metrics.firstRenderTime.toFixed(2)}ms`,
1896
+ '🚀 Temps Total Startup': `${metrics.totalStartupTime.toFixed(2)}ms`,
1897
+ '📊 FPS Actuel': this.fps
1898
+ });
1899
+ }
1900
+
1901
+ // ✅ AJOUTER: Obtenir les métriques
1902
+ getMetrics() {
1903
+ return {
1904
+ ...this.metrics,
1905
+ currentFPS: this.fps,
1906
+ componentsCount: this.components.length
1907
+ };
1908
+ }
1610
1909
 
1910
+ isFixedComponent(comp) {
1911
+ return FIXED_COMPONENT_TYPES.has(comp.constructor);
1912
+ }
1611
1913
 
1914
+ showToast(message, duration = 3000) {
1915
+ const toast = new Toast(this, {
1916
+ text: message,
1917
+ duration: duration,
1918
+ x: this.width / 2,
1919
+ y: this.height - 100
1920
+ });
1921
+ this.add(toast);
1922
+ toast.show();
1923
+ }
1924
+ }
1612
1925
 
1926
+ export default CanvasFramework;