canvasframework 0.4.6 → 0.4.8

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