canvasframework 0.5.18 → 0.5.20

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.
Files changed (113) hide show
  1. package/README.md +30 -0
  2. package/components/Accordion.js +265 -0
  3. package/components/AndroidDatePickerDialog.js +406 -0
  4. package/components/AppBar.js +398 -0
  5. package/components/AudioPlayer.js +611 -0
  6. package/components/Avatar.js +202 -0
  7. package/components/Banner.js +342 -0
  8. package/components/BottomNavigationBar.js +433 -0
  9. package/components/BottomSheet.js +234 -0
  10. package/components/Button.js +358 -0
  11. package/components/Camera.js +644 -0
  12. package/components/Card.js +193 -0
  13. package/components/Chart.js +700 -0
  14. package/components/Checkbox.js +166 -0
  15. package/components/Chip.js +212 -0
  16. package/components/CircularProgress.js +327 -0
  17. package/components/ContextMenu.js +116 -0
  18. package/components/DatePicker.js +298 -0
  19. package/components/Dialog.js +337 -0
  20. package/components/Divider.js +125 -0
  21. package/components/Drawer.js +276 -0
  22. package/components/FAB.js +270 -0
  23. package/components/FileUpload.js +315 -0
  24. package/components/FloatedCamera.js +644 -0
  25. package/components/IOSDatePickerWheel.js +430 -0
  26. package/components/ImageCarousel.js +219 -0
  27. package/components/ImageComponent.js +223 -0
  28. package/components/Input.js +831 -0
  29. package/components/InputDatalist.js +723 -0
  30. package/components/InputTags.js +624 -0
  31. package/components/List.js +95 -0
  32. package/components/ListItem.js +269 -0
  33. package/components/Modal.js +364 -0
  34. package/components/MorphingFAB.js +428 -0
  35. package/components/MultiSelectDialog.js +206 -0
  36. package/components/NumberInput.js +271 -0
  37. package/components/PasswordInput.js +462 -0
  38. package/components/ProgressBar.js +88 -0
  39. package/components/QRCodeReader.js +539 -0
  40. package/components/RadioButton.js +151 -0
  41. package/components/SearchInput.js +315 -0
  42. package/components/SegmentedControl.js +357 -0
  43. package/components/Select.js +199 -0
  44. package/components/SelectDialog.js +255 -0
  45. package/components/Slider.js +113 -0
  46. package/components/SliverAppBar.js +139 -0
  47. package/components/Snackbar.js +243 -0
  48. package/components/SpeedDialFAB.js +397 -0
  49. package/components/Stepper.js +281 -0
  50. package/components/SwipeableListItem.js +327 -0
  51. package/components/Switch.js +147 -0
  52. package/components/Table.js +492 -0
  53. package/components/Tabs.js +423 -0
  54. package/components/Text.js +141 -0
  55. package/components/TextField.js +151 -0
  56. package/components/TimePicker.js +934 -0
  57. package/components/Toast.js +236 -0
  58. package/components/TreeView.js +420 -0
  59. package/components/Video.js +397 -0
  60. package/components/View.js +140 -0
  61. package/components/VirtualList.js +120 -0
  62. package/core/CanvasFramework.js +3045 -0
  63. package/core/Component.js +243 -0
  64. package/core/ThemeManager.js +358 -0
  65. package/core/UIBuilder.js +267 -0
  66. package/core/WebGLCanvasAdapter.js +782 -0
  67. package/features/Column.js +43 -0
  68. package/features/Grid.js +47 -0
  69. package/features/LayoutComponent.js +43 -0
  70. package/features/OpenStreetMap.js +310 -0
  71. package/features/Positioned.js +33 -0
  72. package/features/PullToRefresh.js +328 -0
  73. package/features/Row.js +40 -0
  74. package/features/SignaturePad.js +257 -0
  75. package/features/Skeleton.js +193 -0
  76. package/features/Stack.js +21 -0
  77. package/index.js +119 -0
  78. package/manager/AccessibilityManager.js +107 -0
  79. package/manager/ErrorHandler.js +59 -0
  80. package/manager/FeatureFlags.js +60 -0
  81. package/manager/MemoryManager.js +107 -0
  82. package/manager/PerformanceMonitor.js +84 -0
  83. package/manager/SecurityManager.js +54 -0
  84. package/package.json +22 -16
  85. package/utils/AnimationEngine.js +734 -0
  86. package/utils/CryptoManager.js +303 -0
  87. package/utils/DataStore.js +403 -0
  88. package/utils/DevTools.js +1618 -0
  89. package/utils/DevToolsConsole.js +201 -0
  90. package/utils/EventBus.js +407 -0
  91. package/utils/FetchClient.js +74 -0
  92. package/utils/FirebaseAuth.js +653 -0
  93. package/utils/FirebaseCore.js +246 -0
  94. package/utils/FirebaseFirestore.js +581 -0
  95. package/utils/FirebaseFunctions.js +97 -0
  96. package/utils/FirebaseRealtimeDB.js +498 -0
  97. package/utils/FirebaseStorage.js +612 -0
  98. package/utils/FormValidator.js +355 -0
  99. package/utils/GeoLocationService.js +62 -0
  100. package/utils/I18n.js +207 -0
  101. package/utils/IndexedDBManager.js +273 -0
  102. package/utils/InspectionOverlay.js +308 -0
  103. package/utils/NotificationManager.js +60 -0
  104. package/utils/OfflineSyncManager.js +342 -0
  105. package/utils/PayPalPayment.js +678 -0
  106. package/utils/QueryBuilder.js +478 -0
  107. package/utils/SafeArea.js +64 -0
  108. package/utils/SecureStorage.js +289 -0
  109. package/utils/StateManager.js +207 -0
  110. package/utils/StripePayment.js +552 -0
  111. package/utils/WebSocketClient.js +66 -0
  112. package/dist/canvasframework.js +0 -2
  113. package/dist/canvasframework.js.LICENSE.txt +0 -1
@@ -0,0 +1,3045 @@
1
+ import Button from '../components/Button.js';
2
+ import SegmentedControl from '../components/SegmentedControl.js';
3
+ import Input from '../components/Input.js';
4
+ import Slider from '../components/Slider.js';
5
+ import Text from '../components/Text.js';
6
+ import View from '../components/View.js';
7
+ import Card from '../components/Card.js';
8
+ import FAB from '../components/FAB.js';
9
+ import SpeedDialFAB from '../components/SpeedDialFAB.js';
10
+ import MorphingFAB from '../components/MorphingFAB.js';
11
+ import CircularProgress from '../components/CircularProgress.js';
12
+ import ImageComponent from '../components/ImageComponent.js';
13
+ import DatePicker from '../components/DatePicker.js';
14
+ import IOSDatePickerWheel from '../components/IOSDatePickerWheel.js';
15
+ import AndroidDatePickerDialog from '../components/AndroidDatePickerDialog.js';
16
+ import Avatar from '../components/Avatar.js';
17
+ import Snackbar from '../components/Snackbar.js';
18
+ import BottomNavigationBar from '../components/BottomNavigationBar.js';
19
+ import Video from '../components/Video.js';
20
+ import Modal from '../components/Modal.js';
21
+ import Drawer from '../components/Drawer.js';
22
+ import AppBar from '../components/AppBar.js';
23
+ import Chip from '../components/Chip.js';
24
+ import Stepper from '../components/Stepper.js';
25
+ import Accordion from '../components/Accordion.js';
26
+ import Tabs from '../components/Tabs.js';
27
+ import Switch from '../components/Switch.js';
28
+ import SwipeableListItem from '../components/SwipeableListItem.js';
29
+ import ListItem from '../components/ListItem.js';
30
+ import List from '../components/List.js';
31
+ import VirtualList from '../components/VirtualList.js';
32
+ import BottomSheet from '../components/BottomSheet.js';
33
+ import ProgressBar from '../components/ProgressBar.js';
34
+ import RadioButton from '../components/RadioButton.js';
35
+ import Dialog from '../components/Dialog.js';
36
+ import ContextMenu from '../components/ContextMenu.js';
37
+ import Checkbox from '../components/Checkbox.js';
38
+ import Toast from '../components/Toast.js';
39
+ import NumberInput from '../components/NumberInput.js';
40
+ import TextField from '../components/TextField.js';
41
+ import SelectDialog from '../components/SelectDialog.js';
42
+ import Select from '../components/Select.js';
43
+ import MultiSelectDialog from '../components/MultiSelectDialog.js';
44
+ import Divider from '../components/Divider.js';
45
+ import FileUpload from '../components/FileUpload.js';
46
+ import Table from '../components/Table.js';
47
+ import TreeView from '../components/TreeView.js';
48
+ import SearchInput from '../components/SearchInput.js';
49
+ import ImageCarousel from '../components/ImageCarousel.js';
50
+ import PasswordInput from '../components/PasswordInput.js';
51
+ import InputTags from '../components/InputTags.js';
52
+ import InputDatalist from '../components/InputDatalist.js';
53
+ import Banner from '../components/Banner.js';
54
+ import Chart from '../components/Chart.js';
55
+ import SliverAppBar from '../components/SliverAppBar.js';
56
+ import AudioPlayer from '../components/AudioPlayer.js';
57
+ import Camera from '../components/Camera.js';
58
+ import FloatedCamera from '../components/FloatedCamera.js';
59
+ import TimePicker from '../components/TimePicker.js';
60
+ import QRCodeReader from '../components/QRCodeReader.js';
61
+
62
+ // Utils
63
+ import SafeArea from '../utils/SafeArea.js';
64
+ import StateManager from '../utils/StateManager.js';
65
+ import I18n from '../utils/I18n.js';
66
+ import SecureStorage from '../utils/SecureStorage.js';
67
+ import FormValidator from '../utils/FormValidator.js';
68
+ import DataStore from '../utils/DataStore.js';
69
+ import EventBus from '../utils/EventBus.js';
70
+ import IndexedDBManager from '../utils/IndexedDBManager.js';
71
+ import QueryBuilder from '../utils/QueryBuilder.js';
72
+ import OfflineSyncManager from '../utils/OfflineSyncManager.js';
73
+ import FetchClient from '../utils/FetchClient.js';
74
+ import GeoLocationService from '../utils/GeoLocationService.js';
75
+ import WebSocketClient from '../utils/WebSocketClient.js';
76
+ import AnimationEngine from '../utils/AnimationEngine.js';
77
+ import CryptoManager from '../utils/CryptoManager.js';
78
+ import NotificationManager from '../utils/NotificationManager.js';
79
+
80
+ // DevTools
81
+ import DevTools from '../utils/DevTools.js';
82
+ import InspectionOverlay from '../utils/InspectionOverlay.js';
83
+ import DevToolsConsole from '../utils/DevToolsConsole.js';
84
+
85
+ // Features
86
+ import PullToRefresh from '../features/PullToRefresh.js';
87
+ import Skeleton from '../features/Skeleton.js';
88
+ import SignaturePad from '../features/SignaturePad.js';
89
+ import OpenStreetMap from '../features/OpenStreetMap.js';
90
+ import LayoutComponent from '../features/LayoutComponent.js';
91
+ import Grid from '../features/Grid.js';
92
+ import Row from '../features/Row.js';
93
+ import Column from '../features/Column.js';
94
+ import Positioned from '../features/Positioned.js';
95
+ import Stack from '../features/Stack.js';
96
+
97
+ // Manager
98
+ import ErrorHandler from '../manager/ErrorHandler.js';
99
+ import PerformanceMonitor from '../manager/PerformanceMonitor.js';
100
+ import AccessibilityManager from '../manager/AccessibilityManager.js';
101
+ import MemoryManager from '../manager/MemoryManager.js';
102
+ import SecurityManager from '../manager/SecurityManager.js';
103
+ import FeatureFlags from '../manager/FeatureFlags.js';
104
+
105
+ // WebGL Adapter
106
+ import WebGLCanvasAdapter from './WebGLCanvasAdapter.js';
107
+ import ui, {
108
+ createRef
109
+ } from './UIBuilder.js';
110
+ import ThemeManager from './ThemeManager.js';
111
+
112
+
113
+ const FIXED_COMPONENT_TYPES = new Set([
114
+ AppBar,
115
+ BottomNavigationBar,
116
+ Drawer,
117
+ Dialog,
118
+ Modal,
119
+ Tabs,
120
+ FAB,
121
+ Toast,
122
+ Camera,
123
+ QRCodeReader,
124
+ Banner,
125
+ SliverAppBar,
126
+ BottomSheet,
127
+ ContextMenu,
128
+ OpenStreetMap,
129
+ SelectDialog
130
+ ]);
131
+
132
+ /**
133
+ * Framework principal pour créer des interfaces utilisateur basées sur Canvas
134
+ * @class
135
+ * @property {HTMLCanvasElement} canvas - Élément canvas HTML
136
+ * @property {CanvasRenderingContext2D} ctx - Contexte 2D du canvas
137
+ * @property {number} width - Largeur du canvas
138
+ * @property {number} height - Hauteur du canvas
139
+ * @property {number} dpr - Device Pixel Ratio
140
+ * @property {string} platform - Plateforme détectée ('material' ou 'cupertino')
141
+ * @property {Component[]} components - Liste des composants
142
+ * @property {Map} routes - Routes de navigation
143
+ * @property {string} currentRoute - Route actuelle
144
+ * @property {Object} state - État global
145
+ * @property {boolean} isDragging - Indique si un drag est en cours
146
+ * @property {number} lastTouchY - Dernière position Y du touch
147
+ * @property {number} scrollOffset - Offset de défilement
148
+ * @property {number} scrollVelocity - Vitesse de défilement
149
+ * @property {number} scrollFriction - Friction du défilement
150
+ */
151
+ class CanvasFramework {
152
+ /**
153
+ * Crée une instance de CanvasFramework
154
+ * @param {string} canvasId - ID de l'élément canvas
155
+ */
156
+ constructor(canvasId, options = {}) {
157
+ // ✅ AJOUTER: Démarrer le chronomètre
158
+ const startTime = performance.now();
159
+
160
+ this.metrics = {
161
+ initTime: 0,
162
+ firstRenderTime: null,
163
+ firstInteractionTime: null,
164
+ totalStartupTime: null
165
+ };
166
+ this._firstRenderDone = false;
167
+ this._startupStartTime = startTime;
168
+
169
+ // ✅ Créer automatiquement le canvas
170
+ this.canvas = document.createElement('canvas');
171
+ this.canvas.id = canvasId || `canvas-${Date.now()}`;
172
+ this.canvas.style.display = 'block';
173
+ this.canvas.style.touchAction = 'none';
174
+ this.canvas.style.userSelect = 'none';
175
+ document.body.appendChild(this.canvas);
176
+
177
+ // NOUVELLE OPTION: choisir entre Canvas 2D et WebGL
178
+ this.useWebGL = options.useWebGL ?? false; // utilise la valeur si fournie, sinon false
179
+
180
+ // Initialiser le contexte approprié
181
+ if (this.useWebGL) {
182
+ try {
183
+ this.ctx = new WebGLCanvasAdapter(this.canvas, {
184
+ dpr: this.dpr,
185
+ alpha: false
186
+ });
187
+ } catch (err) {
188
+ console.warn("Échec de l’initialisation WebGLCanvasAdapter → fallback Canvas 2D", err);
189
+ this.ctx = this.canvas.getContext('2d', {
190
+ alpha: false,
191
+ desynchronized: true,
192
+ willReadFrequently: false
193
+ });
194
+ this.useWebGL = false;
195
+ }
196
+ } else {
197
+ this.ctx = this.canvas.getContext('2d', {
198
+ alpha: false,
199
+ desynchronized: true,
200
+ willReadFrequently: false
201
+ });
202
+ }
203
+ //this.ctx.scale(this.dpr, this.dpr);
204
+
205
+ this.backgroundColor = options.backgroundColor || '#f5f5f5'; // Blanc par défaut
206
+
207
+ this.width = window.innerWidth;
208
+ this.height = window.innerHeight;
209
+
210
+ this.dpr = window.devicePixelRatio || 1;
211
+
212
+ // ✅ OPTIMISATION OPTION 2: Configuration des optimisations
213
+ this.optimizations = {
214
+ enabled: options.optimizations !== false, // Activé par défaut
215
+ useDoubleBuffering: true,
216
+ useCaching: true,
217
+ useBatchDrawing: true,
218
+ useSpatialPartitioning: false, // Désactivé par défaut (à activer si beaucoup de composants)
219
+ useImageDataOptimization: true
220
+ };
221
+
222
+ // ✅ OPTIMISATION OPTION 2: Cache pour éviter les changements d'état inutiles
223
+ this._stateCache = {
224
+ fillStyle: null,
225
+ strokeStyle: null,
226
+ font: null,
227
+ textAlign: null,
228
+ textBaseline: null,
229
+ lineWidth: null,
230
+ globalAlpha: 1
231
+ };
232
+
233
+ // ✅ OPTIMISATION OPTION 2: Cache des images/textes
234
+ this.imageCache = new Map();
235
+ this.textCache = new Map();
236
+
237
+ // ✅ OPTIMISATION OPTION 2: Double buffering
238
+ this._doubleBuffer = null;
239
+ this._bufferCtx = null;
240
+ if (this.optimizations.useDoubleBuffering) {
241
+ this._initDoubleBuffer();
242
+ }
243
+
244
+ // Scroll Worker
245
+ this.scrollWorker = this.createScrollWorker();
246
+ this.scrollWorker.onmessage = this.handleScrollWorkerMessage.bind(this);
247
+
248
+ this.splashOptions = {
249
+ enabled: options.splash?.enabled === true, // false par défaut
250
+ duration: options.splash?.duration || 700,
251
+ fadeOutDuration: options.splash?.fadeOutDuration || 500,
252
+ backgroundColor: options.splash?.backgroundColor || ['#667eea', '#764ba2'], // Gradient ou couleur unie
253
+ spinnerColor: options.splash?.spinnerColor || 'white',
254
+ spinnerBackground: options.splash?.spinnerBackground || 'rgba(255, 255, 255, 0.3)',
255
+ textColor: options.splash?.textColor || 'white',
256
+ text: options.splash?.text || 'Chargement...',
257
+ textSize: options.splash?.textSize || 20,
258
+ textFont: options.splash?.textFont || 'Arial',
259
+ progressBarColor: options.splash?.progressBarColor || 'white',
260
+ progressBarBackground: options.splash?.progressBarBackground || 'rgba(255, 255, 255, 0.3)',
261
+ showProgressBar: options.splash?.showProgressBar !== false, // true par défaut
262
+ logo: options.splash?.logo || null, // URL d'une image (optionnel)
263
+ logoWidth: options.splash?.logoWidth || 100,
264
+ logoHeight: options.splash?.logoHeight || 100
265
+ };
266
+
267
+ // ✅ MODIFIER : Vérifier si le splash est activé
268
+ if (this.splashOptions.enabled) {
269
+ this.showSplashScreen();
270
+ } else {
271
+ this._splashFinished = true; // Passer directement au rendu
272
+ }
273
+
274
+ this.platform = this.detectPlatform();
275
+ setTimeout(() => {
276
+ this.initScrollWorker();
277
+ }, 100);
278
+ // État actuel + préférence
279
+ this.themeMode = options.themeMode || 'system'; // 'light', 'dark', 'system'
280
+ this.userThemeOverride = null; // null = suit system, sinon 'light' ou 'dark'
281
+
282
+ // Applique le thème initial
283
+ this.setupSystemThemeListener();
284
+
285
+ // Récupère override utilisateur
286
+ const savedOverride = localStorage.getItem('themeOverride');
287
+ if (savedOverride && ['light', 'dark'].includes(savedOverride)) {
288
+ this.userThemeOverride = savedOverride;
289
+ this.themeMode = savedOverride;
290
+ }
291
+
292
+ this.components = [];
293
+ // ✅ AJOUTER ICI :
294
+ this._cachedMaxScroll = 0;
295
+ this._maxScrollDirty = true;
296
+ this.resizeTimeout = null;
297
+
298
+ //this.applyThemeFromSystem();
299
+ this.state = {};
300
+
301
+ // Calcule FPS
302
+ this.fps = 0;
303
+ this._frames = 0;
304
+ this._lastFpsTime = performance.now();
305
+ this.showFps = options.showFps || false; // false par défaut
306
+ this.debbug = options.debug || false; // false par défaut (et correction de la faute de frappe)
307
+
308
+ // Worker pour multithreading Canvas Worker
309
+ this.worker = this.createCanvasWorker();
310
+ this.worker.onmessage = this.handleWorkerMessage.bind(this);
311
+ this.worker.postMessage({
312
+ type: 'INIT',
313
+ payload: {
314
+ components: []
315
+ }
316
+ });
317
+
318
+ // Logic Worker
319
+ this.logicWorker = this.createLogicWorker();
320
+ this.logicWorker.onmessage = this.handleLogicWorkerMessage.bind(this);
321
+ this.logicWorkerState = {};
322
+ this.logicWorker.postMessage({
323
+ type: 'SET_STATE',
324
+ payload: this.state
325
+ });
326
+
327
+ // Envoyer l'état initial au worker
328
+ this.logicWorker.postMessage({
329
+ type: 'SET_STATE',
330
+ payload: this.state
331
+ });
332
+
333
+ // Gestion des événements
334
+ this.isDragging = false;
335
+ this.lastTouchY = 0;
336
+ this.scrollOffset = 0;
337
+ this.scrollVelocity = 0;
338
+ this.scrollFriction = 0.95;
339
+
340
+ // Optimisation
341
+ this.dirtyComponents = new Set();
342
+ this.optimizationEnabled = this.optimizations.enabled;
343
+
344
+ // AJOUTER CETTE LIGNE
345
+ this.animator = new AnimationEngine();
346
+
347
+ // ===== NOUVEAU SYSTÈME DE ROUTING =====
348
+ this.routes = new Map();
349
+ this.currentRoute = '/';
350
+ this.currentParams = {};
351
+ this.currentQuery = {};
352
+ this.history = [];
353
+ this.historyIndex = -1;
354
+
355
+ // Animation de transition
356
+ this.transitionState = {
357
+ isTransitioning: false,
358
+ progress: 0,
359
+ duration: 300,
360
+ type: 'slide', // 'slide', 'fade', 'none'
361
+ direction: 'forward', // 'forward', 'back'
362
+ oldComponents: [],
363
+ newComponents: []
364
+ };
365
+
366
+ this.setupCanvas();
367
+
368
+ // ✅ OPTIMISATION OPTION 5: Désactiver l'antialiasing pour meilleures performances
369
+ this._disableImageSmoothing();
370
+
371
+ this.setupEventListeners();
372
+ this.setupHistoryListener();
373
+
374
+ this.startRenderLoop();
375
+
376
+ this.devTools = new DevTools(this);
377
+ this.inspectionOverlay = new InspectionOverlay(this);
378
+
379
+ // MODIFICATION: Vérifier explicitement l'option enableDevTools
380
+ const shouldEnableDevTools = options.enableDevTools === true;
381
+
382
+ if (shouldEnableDevTools) {
383
+ this.enableDevTools();
384
+ console.log('DevTools enabled. Press Ctrl+Shift+D to toggle.');
385
+ }
386
+
387
+ // Initialiser le ThemeManager
388
+ this.themeManager = new ThemeManager(this, {
389
+ lightTheme: options.lightTheme,
390
+ darkTheme: options.darkTheme,
391
+ storageKey: options.themeStorageKey || 'app-theme-mode'
392
+ });
393
+
394
+ // Raccourci pour accéder au thème actuel
395
+ this.theme = this.themeManager.getTheme();
396
+
397
+ // ✅ AJOUTER: Mesurer le temps d'init
398
+ const initTime = performance.now() - startTime;
399
+
400
+ // ✅ AJOUTER: Stocker les métriques
401
+ this.metrics = {
402
+ initTime: initTime,
403
+ firstRenderTime: null,
404
+ firstInteractionTime: null,
405
+ totalStartupTime: null
406
+ };
407
+
408
+ // ✅ AJOUTER: Logger si debug
409
+ if (options.debug || options.showMetrics) {
410
+ console.log(`⚡ Framework initialisé en ${initTime.toFixed(2)}ms`);
411
+ }
412
+
413
+ // ✅ AJOUTER: Marquer le premier rendu
414
+ this._firstRenderDone = false;
415
+ this._startupStartTime = startTime;
416
+
417
+ // ✅ OPTIMISATION OPTION 5: Partition spatiale pour le culling (optionnel)
418
+ if (this.optimizations.useSpatialPartitioning) {
419
+ this._initSpatialPartitioning();
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Crée un élément DOM temporaire, l'ajoute au body, exécute une callback, puis le supprime
425
+ * @param {string} tagName - 'input', 'select', 'textarea', etc.
426
+ * @param {Object} props - propriétés de base (type, value, accept, placeholder...)
427
+ * @param {Function} onResult - callback quand l'élément change ou blur (reçoit l'élément)
428
+ * @param {Object} [position=null] - {left, top, width, height} en pixels
429
+ * @param {Object} [attributes={}] - attributs supplémentaires (id, className, data-*, etc.)
430
+ * @returns {HTMLElement} L'élément créé (avant suppression)
431
+ */
432
+ createTemporaryDomElement(tagName, props = {}, onResult, position = null, attributes = {}) {
433
+ const el = document.createElement(tagName);
434
+
435
+ // Appliquer les propriétés de base
436
+ Object.assign(el, props);
437
+
438
+ // Appliquer les attributs personnalisés (id, class, data-*, etc.)
439
+ Object.entries(attributes).forEach(([key, value]) => {
440
+ if (key === 'className') {
441
+ el.className = value; // className est spécial en JS
442
+ } else {
443
+ el.setAttribute(key, value);
444
+ }
445
+ });
446
+
447
+ // Styles de base pour le rendre invisible
448
+ el.style.position = 'absolute';
449
+ el.style.opacity = '0';
450
+ el.style.zIndex = '9999';
451
+
452
+ // Positionnement optionnel
453
+ if (position) {
454
+ Object.assign(el.style, {
455
+ left: `${position.left}px`,
456
+ top: `${position.top}px`,
457
+ width: `${position.width}px`,
458
+ height: `${position.height}px`
459
+ });
460
+ }
461
+
462
+ document.body.appendChild(el);
463
+
464
+ const cleanup = () => {
465
+ el.remove();
466
+ document.removeEventListener('focusout', cleanup);
467
+ };
468
+
469
+ // Événements
470
+ el.addEventListener('change', (e) => {
471
+ onResult(e.target);
472
+ cleanup();
473
+ });
474
+
475
+ el.addEventListener('blur', cleanup);
476
+
477
+ // Focus automatique pour les champs saisissables
478
+ if (['input', 'select', 'textarea'].includes(tagName.toLowerCase())) {
479
+ el.focus();
480
+ }
481
+
482
+ return el;
483
+ }
484
+
485
+ /**
486
+ * Crée le Worker pour le calcul du scroll
487
+ */
488
+ createScrollWorker() {
489
+ const workerCode = `
490
+ let state = {
491
+ scrollOffset: 0,
492
+ scrollVelocity: 0,
493
+ scrollFriction: 0.95,
494
+ isDragging: false,
495
+ maxScroll: 0,
496
+ height: 0,
497
+ lastTouchY: 0,
498
+ components: []
499
+ };
500
+
501
+ const FIXED_COMPONENT_TYPES = [
502
+ 'AppBar', 'BottomNavigationBar', 'Drawer', 'Dialog', 'Modal',
503
+ 'Tabs', 'FAB', 'Toast', 'Camera', 'QRCodeReader', 'Banner',
504
+ 'SliverAppBar', 'BottomSheet', 'ContextMenu', 'OpenStreetMap', 'SelectDialog'
505
+ ];
506
+
507
+ const calculateMaxScroll = () => {
508
+ let maxY = 0;
509
+
510
+ for (const comp of state.components) {
511
+ if (FIXED_COMPONENT_TYPES.includes(comp.type) || !comp.visible) continue;
512
+ const bottom = comp.y + comp.height;
513
+ if (bottom > maxY) maxY = bottom;
514
+ }
515
+
516
+ return Math.max(0, maxY - state.height + 50);
517
+ };
518
+
519
+ const updateInertia = () => {
520
+ if (Math.abs(state.scrollVelocity) > 0.1 && !state.isDragging) {
521
+ state.scrollOffset += state.scrollVelocity;
522
+ state.maxScroll = calculateMaxScroll();
523
+ state.scrollOffset = Math.max(Math.min(state.scrollOffset, 0), -state.maxScroll);
524
+ state.scrollVelocity *= state.scrollFriction;
525
+ } else {
526
+ state.scrollVelocity = 0;
527
+ }
528
+
529
+ return {
530
+ scrollOffset: state.scrollOffset,
531
+ scrollVelocity: state.scrollVelocity,
532
+ maxScroll: state.maxScroll
533
+ };
534
+ };
535
+
536
+ const handleTouchMove = (deltaY) => {
537
+ if (state.isDragging) {
538
+ state.scrollOffset += deltaY;
539
+ state.maxScroll = calculateMaxScroll();
540
+ state.scrollOffset = Math.max(Math.min(state.scrollOffset, 0), -state.maxScroll);
541
+ state.scrollVelocity = deltaY;
542
+ return {
543
+ scrollOffset: state.scrollOffset,
544
+ scrollVelocity: state.scrollVelocity,
545
+ maxScroll: state.maxScroll
546
+ };
547
+ }
548
+ return null;
549
+ };
550
+
551
+ self.onmessage = (e) => {
552
+ const { type, payload } = e.data;
553
+
554
+ switch (type) {
555
+ case 'INIT':
556
+ state = {
557
+ ...state,
558
+ ...payload
559
+ };
560
+ state.maxScroll = calculateMaxScroll();
561
+ self.postMessage({
562
+ type: 'INITIALIZED',
563
+ payload: {
564
+ scrollOffset: state.scrollOffset,
565
+ maxScroll: state.maxScroll
566
+ }
567
+ });
568
+ break;
569
+
570
+ case 'UPDATE_COMPONENTS':
571
+ state.components = payload.components;
572
+ state.maxScroll = calculateMaxScroll();
573
+ self.postMessage({
574
+ type: 'MAX_SCROLL_UPDATED',
575
+ payload: { maxScroll: state.maxScroll }
576
+ });
577
+ break;
578
+
579
+ case 'UPDATE_DIMENSIONS':
580
+ state.height = payload.height;
581
+ state.maxScroll = calculateMaxScroll();
582
+ self.postMessage({
583
+ type: 'MAX_SCROLL_UPDATED',
584
+ payload: { maxScroll: state.maxScroll }
585
+ });
586
+ break;
587
+
588
+ case 'SET_DRAGGING':
589
+ state.isDragging = payload.isDragging;
590
+ if (!payload.isDragging) {
591
+ state.scrollVelocity = payload.lastVelocity || 0;
592
+ } else {
593
+ state.lastTouchY = payload.lastTouchY || 0;
594
+ }
595
+ break;
596
+
597
+ case 'HANDLE_TOUCH_MOVE':
598
+ const result = handleTouchMove(payload.deltaY);
599
+ if (result) {
600
+ self.postMessage({
601
+ type: 'SCROLL_UPDATED',
602
+ payload: result
603
+ });
604
+ }
605
+ break;
606
+
607
+ case 'UPDATE_INERTIA':
608
+ const inertiaResult = updateInertia();
609
+ self.postMessage({
610
+ type: 'SCROLL_UPDATED',
611
+ payload: inertiaResult
612
+ });
613
+ break;
614
+
615
+ case 'SET_SCROLL_OFFSET':
616
+ state.scrollOffset = payload.scrollOffset;
617
+ state.maxScroll = calculateMaxScroll();
618
+ state.scrollOffset = Math.max(Math.min(state.scrollOffset, 0), -state.maxScroll);
619
+ self.postMessage({
620
+ type: 'SCROLL_UPDATED',
621
+ payload: {
622
+ scrollOffset: state.scrollOffset,
623
+ maxScroll: state.maxScroll
624
+ }
625
+ });
626
+ break;
627
+
628
+ case 'GET_STATE':
629
+ self.postMessage({
630
+ type: 'STATE',
631
+ payload: {
632
+ scrollOffset: state.scrollOffset,
633
+ scrollVelocity: state.scrollVelocity,
634
+ maxScroll: state.maxScroll,
635
+ isDragging: state.isDragging
636
+ }
637
+ });
638
+ break;
639
+ }
640
+ };
641
+ `;
642
+
643
+ const blob = new Blob([workerCode], {
644
+ type: 'application/javascript'
645
+ });
646
+ return new Worker(URL.createObjectURL(blob));
647
+ }
648
+
649
+ /**
650
+ * Gère les messages du Scroll Worker
651
+ */
652
+ handleScrollWorkerMessage(e) {
653
+ const {
654
+ type,
655
+ payload
656
+ } = e.data;
657
+
658
+ switch (type) {
659
+ case 'SCROLL_UPDATED':
660
+ this.scrollOffset = payload.scrollOffset;
661
+ this.scrollVelocity = payload.scrollVelocity;
662
+ // ✅ CORRECTION IMPORTANTE : Vider le cache dirty pendant le scroll
663
+ if (Math.abs(payload.scrollVelocity) > 0.5) {
664
+ this.dirtyComponents.clear();
665
+ }
666
+ // Mettre à jour le cache
667
+ this._cachedMaxScroll = payload.maxScroll;
668
+ this._maxScrollDirty = false;
669
+
670
+ // Marquer les composants comme sales pour mise à jour visuelle
671
+ if (Math.abs(payload.scrollVelocity) > 0) {
672
+ this.components.forEach(comp => {
673
+ if (!this.isFixedComponent(comp)) {
674
+ this.markComponentDirty(comp);
675
+ }
676
+ });
677
+ }
678
+ break;
679
+
680
+ case 'MAX_SCROLL_UPDATED':
681
+ this._cachedMaxScroll = payload.maxScroll;
682
+ this._maxScrollDirty = false;
683
+ break;
684
+
685
+ case 'INITIALIZED':
686
+ this.scrollOffset = payload.scrollOffset;
687
+ this._cachedMaxScroll = payload.maxScroll;
688
+ this._maxScrollDirty = false;
689
+ break;
690
+
691
+ case 'STATE':
692
+ // Synchroniser l'état local
693
+ this.scrollOffset = payload.scrollOffset;
694
+ this.scrollVelocity = payload.scrollVelocity;
695
+ this.isDragging = payload.isDragging;
696
+ this._cachedMaxScroll = payload.maxScroll;
697
+ this._maxScrollDirty = false;
698
+ break;
699
+ }
700
+ }
701
+
702
+ /**
703
+ * Initialise le Scroll Worker avec les données actuelles
704
+ */
705
+ initScrollWorker() {
706
+ const componentsData = this.components.map(comp => ({
707
+ type: comp.constructor.name,
708
+ y: comp.y,
709
+ height: comp.height,
710
+ visible: comp.visible
711
+ }));
712
+
713
+ this.scrollWorker.postMessage({
714
+ type: 'INIT',
715
+ payload: {
716
+ scrollOffset: this.scrollOffset,
717
+ scrollVelocity: this.scrollVelocity,
718
+ scrollFriction: this.scrollFriction,
719
+ isDragging: this.isDragging,
720
+ height: this.height,
721
+ components: componentsData
722
+ }
723
+ });
724
+ }
725
+
726
+ /**
727
+ * Met à jour les composants dans le Worker
728
+ */
729
+ updateScrollWorkerComponents() {
730
+ const componentsData = this.components.map(comp => ({
731
+ type: comp.constructor.name,
732
+ y: comp.y,
733
+ height: comp.height,
734
+ visible: comp.visible
735
+ }));
736
+
737
+ this.scrollWorker.postMessage({
738
+ type: 'UPDATE_COMPONENTS',
739
+ payload: {
740
+ components: componentsData
741
+ }
742
+ });
743
+ }
744
+
745
+ /**
746
+ * Initialise le double buffering pour éviter le flickering
747
+ * @private
748
+ */
749
+ // Dans _initDoubleBuffer(), assurez-vous de bien configurer le contexte
750
+ _initDoubleBuffer() {
751
+ this._doubleBuffer = document.createElement('canvas');
752
+ this._bufferCtx = this._doubleBuffer.getContext('2d', {
753
+ alpha: false,
754
+ desynchronized: true
755
+ });
756
+ this._doubleBuffer.width = this.width * this.dpr;
757
+ this._doubleBuffer.height = this.height * this.dpr;
758
+ this._doubleBuffer.style.width = this.width + 'px';
759
+ this._doubleBuffer.style.height = this.height + 'px';
760
+ this._bufferCtx.scale(this.dpr, this.dpr);
761
+ this._disableImageSmoothing(this._bufferCtx);
762
+
763
+ // ✅ INITIALISER le background du buffer
764
+ this._bufferCtx.fillStyle = this.backgroundColor || '#ffffff';
765
+ this._bufferCtx.fillRect(0, 0, this.width, this.height);
766
+ }
767
+
768
+
769
+ /**
770
+ * Désactive l'antialiasing pour meilleures performances
771
+ * @private
772
+ * @param {CanvasRenderingContext2D} [ctx=this.ctx] - Contexte à configurer
773
+ */
774
+ _disableImageSmoothing(ctx = this.ctx) {
775
+ ctx.imageSmoothingEnabled = false;
776
+ ctx.msImageSmoothingEnabled = false;
777
+ ctx.webkitImageSmoothingEnabled = false;
778
+ ctx.mozImageSmoothingEnabled = false;
779
+ }
780
+
781
+ /**
782
+ * Initialise le spatial partitioning pour le viewport culling
783
+ * @private
784
+ */
785
+ _initSpatialPartitioning() {
786
+ // Simple grid spatial partitioning
787
+ this._spatialGrid = {
788
+ cellSize: 100,
789
+ grid: new Map(),
790
+ update: (components) => {
791
+ this._spatialGrid.grid.clear();
792
+ components.forEach(comp => {
793
+ if (!comp.visible) return;
794
+
795
+ const gridX = Math.floor(comp.x / this._spatialGrid.cellSize);
796
+ const gridY = Math.floor(comp.y / this._spatialGrid.cellSize);
797
+ const key = `${gridX},${gridY}`;
798
+
799
+ if (!this._spatialGrid.grid.has(key)) {
800
+ this._spatialGrid.grid.set(key, []);
801
+ }
802
+ this._spatialGrid.grid.get(key).push(comp);
803
+ });
804
+ },
805
+ getVisible: (viewportY) => {
806
+ const visible = [];
807
+ const startY = viewportY - 200; // Marge de 200px
808
+ const endY = viewportY + this.height + 200;
809
+
810
+ this._spatialGrid.grid.forEach((comps, key) => {
811
+ comps.forEach(comp => {
812
+ const compBottom = comp.y + comp.height;
813
+ if (compBottom >= startY && comp.y <= endY) {
814
+ visible.push(comp);
815
+ }
816
+ });
817
+ });
818
+ return visible;
819
+ }
820
+ };
821
+ }
822
+
823
+ /**
824
+ * ✅ OPTIMISATION OPTION 2: Rendu optimisé de rectangles
825
+ * Évite les changements d'état inutiles
826
+ * @param {number} x - Position X
827
+ * @param {number} y - Position Y
828
+ * @param {number} w - Largeur
829
+ * @param {number} h - Hauteur
830
+ * @param {string} color - Couleur de remplissage
831
+ */
832
+ fillRectOptimized(x, y, w, h, color) {
833
+ // Éviter les changements d'état inutiles
834
+ if (this._stateCache.fillStyle !== color) {
835
+ this.ctx.fillStyle = color;
836
+ this._stateCache.fillStyle = color;
837
+ }
838
+ this.ctx.fillRect(x, y, w, h);
839
+ }
840
+
841
+ /**
842
+ * ✅ OPTIMISATION OPTION 2: Texte avec cache
843
+ * Cache le rendu du texte pour éviter de le redessiner à chaque frame
844
+ * @param {string} text - Texte à afficher
845
+ * @param {number} x - Position X
846
+ * @param {number} y - Position Y
847
+ * @param {string} font - Police CSS
848
+ * @param {string} color - Couleur du texte
849
+ */
850
+ fillTextCached(text, x, y, font, color) {
851
+ const key = `${text}_${font}_${color}`;
852
+
853
+ if (!this.textCache.has(key)) {
854
+ // Rendu dans un canvas temporaire
855
+ const temp = document.createElement('canvas');
856
+ const tempCtx = temp.getContext('2d', {
857
+ alpha: false
858
+ });
859
+ tempCtx.font = font;
860
+
861
+ const metrics = tempCtx.measureText(text);
862
+ temp.width = Math.ceil(metrics.width);
863
+ temp.height = Math.ceil(parseInt(font) * 1.2);
864
+
865
+ tempCtx.font = font;
866
+ tempCtx.fillStyle = color;
867
+ tempCtx.textBaseline = 'top';
868
+ tempCtx.fillText(text, 0, 0);
869
+
870
+ this.textCache.set(key, {
871
+ canvas: temp,
872
+ width: temp.width,
873
+ height: temp.height,
874
+ baseline: parseInt(font)
875
+ });
876
+ }
877
+
878
+ const cached = this.textCache.get(key);
879
+ this.ctx.drawImage(cached.canvas, x, y - cached.baseline);
880
+ }
881
+
882
+ /**
883
+ * ✅ OPTIMISATION OPTION 5: Rendu batché pour plusieurs rectangles
884
+ * Regroupe les rectangles par couleur pour réduire les appels draw
885
+ * @param {Array} rects - Tableau d'objets {x, y, width, height, color}
886
+ */
887
+ batchRect(rects) {
888
+ if (!rects || rects.length === 0) return;
889
+
890
+ // Regrouper par couleur
891
+ const batches = new Map();
892
+
893
+ rects.forEach(rect => {
894
+ if (!batches.has(rect.color)) {
895
+ batches.set(rect.color, []);
896
+ }
897
+ batches.get(rect.color).push(rect);
898
+ });
899
+
900
+ // Dessiner par batch
901
+ batches.forEach((batchRects, color) => {
902
+ this.ctx.fillStyle = color;
903
+
904
+ // Utiliser un seul path pour tous les rectangles de même couleur
905
+ this.ctx.beginPath();
906
+ batchRects.forEach(rect => {
907
+ this.ctx.rect(rect.x, rect.y, rect.width, rect.height);
908
+ });
909
+ this.ctx.fill();
910
+ });
911
+ }
912
+
913
+ /**
914
+ * ✅ OPTIMISATION OPTION 5: Utiliser ImageData pour les mises à jour fréquentes
915
+ * @param {number} x - Position X
916
+ * @param {number} y - Position Y
917
+ * @param {number} width - Largeur
918
+ * @param {number} height - Hauteur
919
+ * @param {Function} drawFn - Fonction pour manipuler les pixels
920
+ */
921
+ updateRegion(x, y, width, height, drawFn) {
922
+ const imageData = this.ctx.getImageData(x, y, width, height);
923
+ const data = imageData.data;
924
+
925
+ // Manipuler directement les pixels
926
+ drawFn(data, width, height);
927
+
928
+ this.ctx.putImageData(imageData, x, y);
929
+ }
930
+
931
+ /**
932
+ * ✅ OPTIMISATION OPTION 2: Flush du buffer pour le double buffering
933
+ */
934
+ flush() {
935
+ if (this.optimizations.useDoubleBuffering && this._bufferCtx) {
936
+ // Copier tout le buffer sur le canvas réel
937
+ this.ctx.drawImage(this._doubleBuffer, 0, 0, this.width, this.height);
938
+
939
+ // Réinitialiser le buffer pour le prochain frame
940
+ this._bufferCtx.fillStyle = this.backgroundColor || '#ffffff';
941
+ this._bufferCtx.fillRect(0, 0, this.width, this.height);
942
+ }
943
+ }
944
+
945
+ /**
946
+ * ✅ OPTIMISATION OPTION 5: Rendu optimisé avec viewport culling
947
+ * @private
948
+ */
949
+ _renderOptimized() {
950
+ const ctx = this.optimizations.useDoubleBuffering ? this._bufferCtx : this.ctx;
951
+
952
+ if (!ctx) return;
953
+
954
+ // Clear le canvas
955
+ ctx.clearRect(0, 0, this.width, this.height);
956
+
957
+ // Séparer les composants fixes et scrollables
958
+ const scrollableComponents = [];
959
+ const fixedComponents = [];
960
+
961
+ for (let comp of this.components) {
962
+ if (this.isFixedComponent(comp)) {
963
+ fixedComponents.push(comp);
964
+ } else {
965
+ scrollableComponents.push(comp);
966
+ }
967
+ }
968
+
969
+ // Rendu des composants scrollables avec viewport culling optimisé
970
+ ctx.save();
971
+ ctx.translate(0, this.scrollOffset);
972
+
973
+ // ✅ OPTIMISATION: Utiliser le spatial partitioning si activé
974
+ if (this.optimizations.useSpatialPartitioning && this._spatialGrid) {
975
+ const visibleComps = this._spatialGrid.getVisible(-this.scrollOffset);
976
+ for (let comp of visibleComps) {
977
+ if (comp.visible) {
978
+ comp.draw(ctx);
979
+ }
980
+ }
981
+ } else {
982
+ // Rendu standard avec culling simple
983
+ for (let comp of scrollableComponents) {
984
+ if (comp.visible) {
985
+ const screenY = comp.y + this.scrollOffset;
986
+ const isInViewport = screenY + comp.height >= -100 && screenY <= this.height + 100;
987
+
988
+ if (isInViewport) {
989
+ comp.draw(ctx);
990
+ }
991
+ }
992
+ }
993
+ }
994
+
995
+ ctx.restore();
996
+
997
+ // Rendu des composants fixes
998
+ for (let comp of fixedComponents) {
999
+ if (comp.visible) {
1000
+ comp.draw(ctx);
1001
+ }
1002
+ }
1003
+
1004
+ // Flush si on utilise le double buffering
1005
+ if (this.optimizations.useDoubleBuffering) {
1006
+ this.flush();
1007
+ }
1008
+ }
1009
+
1010
+ /**
1011
+ * ✅ OPTIMISATION OPTION 2: Rendu partiel (seulement les composants sales)
1012
+ * @private
1013
+ */
1014
+ _renderDirtyComponents() {
1015
+ const ctx = this.optimizations.useDoubleBuffering ? this._bufferCtx : this.ctx;
1016
+
1017
+ if (!ctx || this.dirtyComponents.size === 0) return;
1018
+
1019
+ // ✅ CORRECTION : Ne pas nettoyer avec fillRect !
1020
+ // À la place, utiliser le clipping pour redessiner proprement
1021
+
1022
+ // Copier dans un tableau
1023
+ const dirtyArray = Array.from(this.dirtyComponents);
1024
+
1025
+ // Vider immédiatement
1026
+ this.dirtyComponents.clear();
1027
+
1028
+ // Séparer les composants
1029
+ const fixedComps = [];
1030
+ const scrollableComps = [];
1031
+
1032
+ dirtyArray.forEach(comp => {
1033
+ if (!comp.visible) return;
1034
+
1035
+ if (this.isFixedComponent(comp)) {
1036
+ fixedComps.push(comp);
1037
+ } else {
1038
+ scrollableComps.push(comp);
1039
+ }
1040
+ });
1041
+
1042
+ // ✅ APPROCHE CORRECTE : Redessiner les composants sales
1043
+ // sans effacer leur zone d'abord
1044
+
1045
+ // 1. Dessiner les scrollables
1046
+ if (scrollableComps.length > 0) {
1047
+ ctx.save();
1048
+ ctx.translate(0, this.scrollOffset);
1049
+
1050
+ for (let comp of scrollableComps) {
1051
+ // ✅ NE PAS faire de fillRect !
1052
+ // Juste dessiner le composant
1053
+ if (comp.draw) {
1054
+ comp.draw(ctx);
1055
+ }
1056
+
1057
+ if (comp.markClean) {
1058
+ comp.markClean();
1059
+ }
1060
+ }
1061
+
1062
+ ctx.restore();
1063
+ }
1064
+
1065
+ // 2. Dessiner les fixes
1066
+ for (let comp of fixedComps) {
1067
+ if (comp.draw) {
1068
+ comp.draw(ctx);
1069
+ }
1070
+
1071
+ if (comp.markClean) {
1072
+ comp.markClean();
1073
+ }
1074
+ }
1075
+
1076
+ // Flush si double buffering
1077
+ if (this.optimizations.useDoubleBuffering) {
1078
+ this.flush();
1079
+ }
1080
+ }
1081
+
1082
+ /**
1083
+ * Active/désactive une optimisation spécifique
1084
+ * @param {string} optimization - Nom de l'optimisation
1085
+ * @param {boolean} enabled - true pour activer, false pour désactiver
1086
+ */
1087
+ setOptimization(optimization, enabled) {
1088
+ if (this.optimizations.hasOwnProperty(optimization)) {
1089
+ this.optimizations[optimization] = enabled;
1090
+
1091
+ switch (optimization) {
1092
+ case 'useDoubleBuffering':
1093
+ if (enabled && !this._bufferCtx) {
1094
+ this._initDoubleBuffer();
1095
+ }
1096
+ break;
1097
+ case 'useSpatialPartitioning':
1098
+ if (enabled && !this._spatialGrid) {
1099
+ this._initSpatialPartitioning();
1100
+ }
1101
+ break;
1102
+ }
1103
+
1104
+ // Marquer tous les composants comme sales pour forcer un redessin complet
1105
+ this.components.forEach(comp => this.markComponentDirty(comp));
1106
+ }
1107
+ }
1108
+
1109
+ /**
1110
+ * Obtient l'état des optimisations
1111
+ * @returns {Object} État des optimisations
1112
+ */
1113
+ getOptimizations() {
1114
+ return {
1115
+ ...this.optimizations
1116
+ };
1117
+ }
1118
+
1119
+ /**
1120
+ * Affiche un écran de chargement animé
1121
+ * @private
1122
+ */
1123
+ showSplashScreen() {
1124
+ const startTime = performance.now();
1125
+ const opts = this.splashOptions;
1126
+
1127
+ // ✅ Charger le logo si présent
1128
+ let logoImage = null;
1129
+ if (opts.logo) {
1130
+ logoImage = new Image();
1131
+ logoImage.src = opts.logo;
1132
+ }
1133
+
1134
+ const animate = () => {
1135
+ const elapsed = performance.now() - startTime;
1136
+ const progress = Math.min(elapsed / opts.duration, 1);
1137
+
1138
+ // Clear
1139
+ this.ctx.clearRect(0, 0, this.width, this.height);
1140
+
1141
+ // ✅ Background (gradient ou couleur unie)
1142
+ if (Array.isArray(opts.backgroundColor) && opts.backgroundColor.length >= 2) {
1143
+ // Gradient
1144
+ const gradient = this.ctx.createLinearGradient(0, 0, this.width, this.height);
1145
+ gradient.addColorStop(0, opts.backgroundColor[0]);
1146
+ gradient.addColorStop(1, opts.backgroundColor[1]);
1147
+ this.ctx.fillStyle = gradient;
1148
+ } else {
1149
+ // Couleur unie
1150
+ this.ctx.fillStyle = opts.backgroundColor;
1151
+ }
1152
+ this.ctx.fillRect(0, 0, this.width, this.height);
1153
+
1154
+ const centerX = this.width / 2;
1155
+ const centerY = this.height / 2;
1156
+
1157
+ // ✅ Logo (si présent et chargé)
1158
+ if (logoImage && logoImage.complete) {
1159
+ const logoX = centerX - opts.logoWidth / 2;
1160
+ const logoY = centerY - opts.logoHeight - 80;
1161
+ this.ctx.drawImage(logoImage, logoX, logoY, opts.logoWidth, opts.logoHeight);
1162
+ }
1163
+
1164
+ // ✅ Spinner animé
1165
+ const radius = 40;
1166
+ const rotation = (elapsed / 1000) * Math.PI * 2;
1167
+
1168
+ // Cercle de fond
1169
+ this.ctx.beginPath();
1170
+ this.ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
1171
+ this.ctx.strokeStyle = opts.spinnerBackground;
1172
+ this.ctx.lineWidth = 4;
1173
+ this.ctx.stroke();
1174
+
1175
+ // Arc animé
1176
+ this.ctx.beginPath();
1177
+ this.ctx.arc(centerX, centerY, radius, rotation, rotation + Math.PI * 1.5);
1178
+ this.ctx.strokeStyle = opts.spinnerColor;
1179
+ this.ctx.lineWidth = 4;
1180
+ this.ctx.lineCap = 'round';
1181
+ this.ctx.stroke();
1182
+
1183
+ // ✅ Texte personnalisé
1184
+ this.ctx.fillStyle = opts.textColor;
1185
+ this.ctx.font = `${opts.textSize}px ${opts.textFont}`;
1186
+ this.ctx.textAlign = 'center';
1187
+ this.ctx.fillText(opts.text, centerX, centerY + radius + 40);
1188
+
1189
+ // ✅ Barre de progression (optionnelle)
1190
+ if (opts.showProgressBar) {
1191
+ const barWidth = 200;
1192
+ const barHeight = 4;
1193
+ const barX = centerX - barWidth / 2;
1194
+ const barY = centerY + radius + 70;
1195
+
1196
+ // Fond de la barre
1197
+ this.ctx.fillStyle = opts.progressBarBackground;
1198
+ this.ctx.fillRect(barX, barY, barWidth, barHeight);
1199
+
1200
+ // Progression
1201
+ this.ctx.fillStyle = opts.progressBarColor;
1202
+ this.ctx.fillRect(barX, barY, barWidth * progress, barHeight);
1203
+ }
1204
+
1205
+ // Continuer ou fade out
1206
+ if (progress < 1) {
1207
+ requestAnimationFrame(animate);
1208
+ } else {
1209
+ this.fadeOutSplash();
1210
+ }
1211
+ };
1212
+
1213
+ animate();
1214
+ }
1215
+
1216
+ /**
1217
+ * Fade out du splash screen
1218
+ * @private
1219
+ */
1220
+ fadeOutSplash() {
1221
+ const opts = this.splashOptions;
1222
+ const duration = opts.fadeOutDuration;
1223
+ const startTime = performance.now();
1224
+
1225
+ const fade = () => {
1226
+ const elapsed = performance.now() - startTime;
1227
+ const progress = elapsed / duration;
1228
+ const alpha = 1 - Math.min(progress, 1);
1229
+
1230
+ if (alpha > 0) {
1231
+ this.ctx.clearRect(0, 0, this.width, this.height);
1232
+ this.ctx.globalAlpha = alpha;
1233
+
1234
+ // Redessiner le background
1235
+ if (Array.isArray(opts.backgroundColor) && opts.backgroundColor.length >= 2) {
1236
+ const gradient = this.ctx.createLinearGradient(0, 0, this.width, this.height);
1237
+ gradient.addColorStop(0, opts.backgroundColor[0]);
1238
+ gradient.addColorStop(1, opts.backgroundColor[1]);
1239
+ this.ctx.fillStyle = gradient;
1240
+ } else {
1241
+ this.ctx.fillStyle = opts.backgroundColor;
1242
+ }
1243
+ this.ctx.fillRect(0, 0, this.width, this.height);
1244
+
1245
+ // Spinner pendant le fade
1246
+ const centerX = this.width / 2;
1247
+ const centerY = this.height / 2;
1248
+ const radius = 40;
1249
+
1250
+ this.ctx.beginPath();
1251
+ this.ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
1252
+ this.ctx.strokeStyle = opts.spinnerBackground;
1253
+ this.ctx.lineWidth = 4;
1254
+ this.ctx.stroke();
1255
+
1256
+ this.ctx.globalAlpha = 1;
1257
+ requestAnimationFrame(fade);
1258
+ } else {
1259
+ this._splashFinished = true;
1260
+ // ✅ AJOUTER : Réinitialiser complètement le contexte
1261
+ this.ctx.clearRect(0, 0, this.width, this.height);
1262
+ this.ctx.globalAlpha = 1;
1263
+ this.ctx.textAlign = 'start'; // ← IMPORTANT
1264
+ this.ctx.textBaseline = 'alphabetic'; // ← IMPORTANT
1265
+ this.ctx.font = '10px sans-serif'; // Valeur par défaut
1266
+ this.ctx.fillStyle = '#000000';
1267
+ this.ctx.strokeStyle = '#000000';
1268
+ this.ctx.lineWidth = 1;
1269
+ this.ctx.lineCap = 'butt';
1270
+ this.ctx.lineJoin = 'miter';
1271
+ }
1272
+ };
1273
+
1274
+ fade();
1275
+ }
1276
+
1277
+ // ✅ AJOUTER: Méthode pour mesurer le premier rendu
1278
+ _markFirstRender() {
1279
+ if (!this._firstRenderDone) {
1280
+ this._firstRenderDone = true;
1281
+ const firstRenderTime = performance.now() - this._startupStartTime - this.metrics.initTime;
1282
+ this.metrics.firstRenderTime = firstRenderTime;
1283
+ this.metrics.totalStartupTime = performance.now() - this._startupStartTime;
1284
+
1285
+ if (this.showMetrics) {
1286
+ console.log(`🎨 Premier rendu en ${firstRenderTime.toFixed(2)}ms`);
1287
+ console.log(`🚀 Temps total de démarrage: ${this.metrics.totalStartupTime.toFixed(2)}ms`);
1288
+ this.displayMetrics();
1289
+ }
1290
+ }
1291
+ }
1292
+
1293
+ /**
1294
+ * Écoute les changements système (ex: utilisateur bascule dark mode)
1295
+ */
1296
+ setupSystemThemeListener() {
1297
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
1298
+
1299
+ // Ancienne méthode (compatibilité large)
1300
+ if (mediaQuery.addEventListener) {
1301
+ mediaQuery.addEventListener('change', (e) => {
1302
+ if (this.themeMode === 'system') {
1303
+ this.applyThemeFromSystem();
1304
+ }
1305
+ });
1306
+ } else {
1307
+ // Anciens navigateurs (rare en 2026)
1308
+ mediaQuery.addListener((e) => {
1309
+ if (this.themeMode === 'system') {
1310
+ this.applyThemeFromSystem();
1311
+ }
1312
+ });
1313
+ }
1314
+ }
1315
+
1316
+ /**
1317
+ * Change le mode thème
1318
+ * @param {'light'|'dark'|'system'} mode - Mode à appliquer
1319
+ * @param {boolean} [save=true] - Sauvegarder le choix utilisateur ?
1320
+ */
1321
+ setThemeMode(mode) {
1322
+ this.themeManager.setMode(mode);
1323
+ this.theme = this.themeManager.getTheme();
1324
+ }
1325
+
1326
+ /**
1327
+ * Obtient une couleur du thème
1328
+ */
1329
+ getColor(colorName) {
1330
+ return this.themeManager.getColor(colorName);
1331
+ }
1332
+
1333
+ /**
1334
+ * Ajoute un listener de changement de thème
1335
+ */
1336
+ onThemeChange(callback) {
1337
+ this.themeManager.addListener((theme) => {
1338
+ this.theme = theme;
1339
+ callback(theme);
1340
+ });
1341
+ }
1342
+
1343
+ /**
1344
+ * Bascule entre light et dark
1345
+ */
1346
+ toggleTheme() {
1347
+ this.themeManager.toggle();
1348
+ this.theme = this.themeManager.getTheme();
1349
+ }
1350
+
1351
+ /**
1352
+ * Active ou désactive les DevTools
1353
+ * @param {boolean} enabled - true pour activer, false pour désactiver
1354
+ */
1355
+ enableDevTools(enabled = true) {
1356
+ if (enabled) {
1357
+ // Créer le DevTools s'il n'existe pas
1358
+ if (!this.devTools) {
1359
+ this.devTools = new DevTools(this);
1360
+ }
1361
+
1362
+ // Attacher seulement si pas déjà fait
1363
+ if (!this.devTools._isAttached) {
1364
+ this.devTools.attachToFramework();
1365
+ this.devTools._isAttached = true;
1366
+ }
1367
+
1368
+ // Afficher le bouton
1369
+ if (this.devTools.toggleBtn) {
1370
+ this.devTools.toggleBtn.style.display = 'block';
1371
+ }
1372
+ } else {
1373
+ // Désactiver complètement
1374
+ if (this.devTools) {
1375
+ // Détacher du framework
1376
+ if (this.devTools.detachFromFramework) {
1377
+ this.devTools.detachFromFramework();
1378
+ } else if (this.devTools.cleanup) {
1379
+ this.devTools.cleanup();
1380
+ }
1381
+
1382
+ // Supprimer de la page DOM
1383
+ if (this.devTools.container && this.devTools.container.parentNode) {
1384
+ this.devTools.container.parentNode.removeChild(this.devTools.container);
1385
+ }
1386
+
1387
+ if (this.devTools.toggleBtn && this.devTools.toggleBtn.parentNode) {
1388
+ this.devTools.toggleBtn.parentNode.removeChild(this.devTools.toggleBtn);
1389
+ }
1390
+
1391
+ this.devTools._isAttached = false;
1392
+ }
1393
+ }
1394
+ }
1395
+
1396
+ /**
1397
+ * Bascule l'overlay d'inspection
1398
+ */
1399
+ toggleInspection() {
1400
+ this.inspectionOverlay.toggle();
1401
+ }
1402
+
1403
+ /**
1404
+ * Exécute une commande DevTools
1405
+ */
1406
+ devToolsCommand(command, ...args) {
1407
+ switch (command) {
1408
+ case 'inspect':
1409
+ this.inspectionOverlay.enable();
1410
+ break;
1411
+ case 'performance':
1412
+ this.devTools.switchTab('performance');
1413
+ this.devTools.toggle();
1414
+ break;
1415
+ case 'components':
1416
+ this.devTools.switchTab('components');
1417
+ this.devTools.toggle();
1418
+ break;
1419
+ case 'highlight':
1420
+ if (args[0]) {
1421
+ this.devTools.highlightComponent(args[0]);
1422
+ }
1423
+ break;
1424
+ case 'reflow':
1425
+ this.components.forEach(comp => comp.markDirty());
1426
+ break;
1427
+ }
1428
+ }
1429
+
1430
+ wrapContext(ctx, theme) {
1431
+ const originalFillStyle = Object.getOwnPropertyDescriptor(CanvasRenderingContext2D.prototype, 'fillStyle');
1432
+ Object.defineProperty(ctx, 'fillStyle', {
1433
+ set: (value) => {
1434
+ // Si value est blanc/noir ou une couleur “neutre”, tu remplaces par theme
1435
+ if (value === '#FFFFFF' || value === '#000000') {
1436
+ originalFillStyle.set.call(ctx, value);
1437
+ } else {
1438
+ originalFillStyle.set.call(ctx, value);
1439
+ }
1440
+ },
1441
+ get: () => originalFillStyle.get.call(ctx)
1442
+ });
1443
+ }
1444
+
1445
+ createCanvasWorker() {
1446
+ const workerCode = `
1447
+ let components = [];
1448
+
1449
+ self.onmessage = function(e) {
1450
+ const { type, payload } = e.data;
1451
+
1452
+ switch(type) {
1453
+ case 'INIT':
1454
+ components = payload.components;
1455
+ self.postMessage({ type: 'READY' });
1456
+ break;
1457
+
1458
+ case 'UPDATE_LAYOUT':
1459
+ const updated = components.map(comp => {
1460
+ if (comp.dynamicHeight && comp.calculateHeight) {
1461
+ comp.height = comp.calculateHeight();
1462
+ }
1463
+ return { id: comp.id, height: comp.height };
1464
+ });
1465
+ self.postMessage({ type: 'LAYOUT_DONE', payload: updated });
1466
+ break;
1467
+
1468
+ case 'SCROLL_INERTIA':
1469
+ let { offset, velocity, friction, maxScroll } = payload;
1470
+ offset += velocity;
1471
+ offset = Math.max(Math.min(offset, 0), -maxScroll);
1472
+ velocity *= friction;
1473
+ self.postMessage({ type: 'SCROLL_UPDATED', payload: { offset, velocity } });
1474
+ break;
1475
+ }
1476
+ };
1477
+ `;
1478
+
1479
+ const blob = new Blob([workerCode], {
1480
+ type: 'application/javascript'
1481
+ });
1482
+ return new Worker(URL.createObjectURL(blob));
1483
+ }
1484
+
1485
+ createLogicWorker() {
1486
+ const workerCode = `
1487
+ let state = {};
1488
+
1489
+ self.onmessage = async function(e) {
1490
+ const { type, payload } = e.data;
1491
+
1492
+ switch(type) {
1493
+ case 'SET_STATE':
1494
+ state = payload;
1495
+ self.postMessage({ type: 'STATE_UPDATED', payload: state });
1496
+ break;
1497
+
1498
+ case 'EXECUTE':
1499
+ try {
1500
+ const fn = new Function('state', 'args', payload.fnString);
1501
+ const result = await fn(state, payload.args);
1502
+ self.postMessage({ type: 'EXECUTION_RESULT', payload: result });
1503
+ } catch (err) {
1504
+ self.postMessage({ type: 'EXECUTION_ERROR', payload: err.message });
1505
+ }
1506
+ break;
1507
+ }
1508
+ };
1509
+ `;
1510
+
1511
+ const blob = new Blob([workerCode], {
1512
+ type: 'application/javascript'
1513
+ });
1514
+ return new Worker(URL.createObjectURL(blob));
1515
+ }
1516
+
1517
+ // Set Theme dynamique
1518
+ setTheme(theme) {
1519
+ this.theme = theme;
1520
+
1521
+ if (!this.useWebGL) {
1522
+ this.wrapContext(this.ctx, theme);
1523
+ }
1524
+
1525
+ // Protège la boucle
1526
+ if (this.components && Array.isArray(this.components)) {
1527
+ this.components.forEach(comp => comp.markDirty());
1528
+ } else {
1529
+ console.warn('[setTheme] components pas encore initialisé');
1530
+ }
1531
+ }
1532
+
1533
+ // Switch Theme
1534
+ toggleDarkMode() {
1535
+ if (this.theme === lightTheme) {
1536
+ this.setTheme(darkTheme);
1537
+ } else {
1538
+ this.setTheme(lightTheme);
1539
+ }
1540
+ }
1541
+
1542
+ enableFpsDisplay(enable = true) {
1543
+ this.showFps = enable;
1544
+ }
1545
+
1546
+ // AJOUTER CETTE MÉTHODE (optionnel - pour faciliter l'accès)
1547
+ animate(component, options) {
1548
+ return this.animator.animate(component, options);
1549
+ }
1550
+
1551
+ // ----- Worker UI -----
1552
+ handleWorkerMessage(e) {
1553
+ const {
1554
+ type,
1555
+ payload
1556
+ } = e.data;
1557
+ switch (type) {
1558
+ case 'LAYOUT_DONE':
1559
+ for (let update of payload) {
1560
+ const comp = this.components.find(c => c.id === update.id);
1561
+ if (comp) comp.height = update.height;
1562
+ }
1563
+ break;
1564
+ case 'SCROLL_UPDATED':
1565
+ this.scrollOffset = payload.offset;
1566
+ this.scrollVelocity = payload.velocity;
1567
+ break;
1568
+ }
1569
+ }
1570
+
1571
+ updateLayoutAsync() {
1572
+ this.worker.postMessage({
1573
+ type: 'UPDATE_LAYOUT'
1574
+ });
1575
+ }
1576
+
1577
+ updateScrollInertia() {
1578
+ const maxScroll = this.getMaxScroll();
1579
+ this.worker.postMessage({
1580
+ type: 'SCROLL_INERTIA',
1581
+ payload: {
1582
+ offset: this.scrollOffset,
1583
+ velocity: this.scrollVelocity,
1584
+ friction: this.scrollFriction,
1585
+ maxScroll
1586
+ }
1587
+ });
1588
+ }
1589
+
1590
+ // ------ Logic Worker --------
1591
+ handleLogicWorkerMessage(e) {
1592
+ const {
1593
+ type,
1594
+ payload
1595
+ } = e.data;
1596
+ switch (type) {
1597
+ case 'STATE_UPDATED':
1598
+ // Le worker a renvoyé le nouvel état global
1599
+ this.logicWorkerState = payload;
1600
+ break;
1601
+
1602
+ case 'EXECUTION_RESULT':
1603
+ // Résultat d'une tâche spécifique envoyée au worker
1604
+ if (this.onWorkerResult) this.onWorkerResult(payload);
1605
+ break;
1606
+
1607
+ case 'EXECUTION_ERROR':
1608
+ console.error('Logic Worker Error:', payload);
1609
+ break;
1610
+ }
1611
+ }
1612
+
1613
+ runLogicTask(taskName, taskData) {
1614
+ this.logicWorker.postMessage({
1615
+ type: 'EXECUTE_TASK',
1616
+ payload: {
1617
+ taskName,
1618
+ taskData
1619
+ }
1620
+ });
1621
+ }
1622
+
1623
+ updateLogicWorkerState(newState) {
1624
+ this.logicWorkerState = {
1625
+ ...this.logicWorkerState,
1626
+ ...newState
1627
+ };
1628
+ this.logicWorker.postMessage({
1629
+ type: 'SET_STATE',
1630
+ payload: this.logicWorkerState
1631
+ });
1632
+ }
1633
+
1634
+ detectPlatform() {
1635
+ const ua = navigator.userAgent.toLowerCase();
1636
+ if (/android/.test(ua)) return 'material';
1637
+ if (/iphone|ipad|ipod/.test(ua)) return 'cupertino';
1638
+ return 'material';
1639
+ }
1640
+
1641
+ setupCanvas() {
1642
+ this.canvas.width = this.width * this.dpr;
1643
+ this.canvas.height = this.height * this.dpr;
1644
+ this.canvas.style.width = this.width + 'px';
1645
+ this.canvas.style.height = this.height + 'px';
1646
+
1647
+ // ✅ AJOUTER: Appliquer le background au style CSS
1648
+ this.canvas.style.backgroundColor = this.backgroundColor;
1649
+ // Échelle uniquement pour Canvas 2D
1650
+ this.ctx.scale(this.dpr, this.dpr);
1651
+ }
1652
+
1653
+ setupEventListeners() {
1654
+ this.canvas.addEventListener('touchstart', this.handleTouchStart.bind(this));
1655
+ this.canvas.addEventListener('touchmove', this.handleTouchMove.bind(this));
1656
+ this.canvas.addEventListener('touchend', this.handleTouchEnd.bind(this));
1657
+ this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
1658
+ this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
1659
+ this.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
1660
+ window.addEventListener('resize', this.handleResize.bind(this));
1661
+ }
1662
+
1663
+ /**
1664
+ * Configure l'écoute de l'historique du navigateur
1665
+ * @private
1666
+ */
1667
+ setupHistoryListener() {
1668
+ window.addEventListener('popstate', (e) => {
1669
+ if (e.state && e.state.route) {
1670
+ this.navigateTo(e.state.route, {
1671
+ replace: true,
1672
+ animate: true,
1673
+ direction: 'back'
1674
+ });
1675
+ }
1676
+ });
1677
+ }
1678
+
1679
+ // ===== MÉTHODES DE ROUTING =====
1680
+
1681
+ /**
1682
+ * Définit une route avec pattern de paramètres
1683
+ * @param {string} pattern - Pattern de la route (ex: '/user/:id', '/posts/:category/:id')
1684
+ * @param {Function} component - Fonction qui crée les composants
1685
+ * @param {Object} options - Options de la route
1686
+ * @returns {CanvasFramework}
1687
+ */
1688
+ route(pattern, component, options = {}) {
1689
+ const route = {
1690
+ pattern,
1691
+ component,
1692
+ regex: this.patternToRegex(pattern),
1693
+ paramNames: this.extractParamNames(pattern),
1694
+ beforeEnter: options.beforeEnter,
1695
+ afterEnter: options.afterEnter,
1696
+ beforeLeave: options.beforeLeave,
1697
+ afterLeave: options.afterLeave, // ✅ NOUVEAU
1698
+ onEnter: options.onEnter, // ✅ NOUVEAU (alias de afterEnter)
1699
+ onLeave: options.onLeave, // ✅ NOUVEAU (alias de beforeLeave)
1700
+ transition: options.transition || 'slide'
1701
+ };
1702
+
1703
+ this.routes.set(pattern, route);
1704
+ return this;
1705
+ }
1706
+
1707
+ /**
1708
+ * Convertit un pattern de route en regex
1709
+ * @private
1710
+ */
1711
+ patternToRegex(pattern) {
1712
+ const regexPattern = pattern
1713
+ .replace(/\//g, '\\/')
1714
+ .replace(/:([^\/]+)/g, '([^\\/]+)');
1715
+ return new RegExp(`^${regexPattern}$`);
1716
+ }
1717
+
1718
+ /**
1719
+ * Extrait les noms des paramètres d'un pattern
1720
+ * @private
1721
+ */
1722
+ extractParamNames(pattern) {
1723
+ const matches = pattern.match(/:([^\/]+)/g);
1724
+ return matches ? matches.map(m => m.slice(1)) : [];
1725
+ }
1726
+
1727
+ /**
1728
+ * Trouve la route correspondant à un path
1729
+ * @private
1730
+ */
1731
+ matchRoute(path) {
1732
+ // Séparer le path et la query string
1733
+ const [pathname, queryString] = path.split('?');
1734
+
1735
+ for (let [pattern, route] of this.routes) {
1736
+ const match = pathname.match(route.regex);
1737
+ if (match) {
1738
+ const params = {};
1739
+ route.paramNames.forEach((name, index) => {
1740
+ params[name] = match[index + 1];
1741
+ });
1742
+
1743
+ const query = this.parseQueryString(queryString);
1744
+
1745
+ return {
1746
+ route,
1747
+ params,
1748
+ query,
1749
+ pathname
1750
+ };
1751
+ }
1752
+ }
1753
+ return null;
1754
+ }
1755
+
1756
+ /**
1757
+ * Parse une query string
1758
+ * @private
1759
+ */
1760
+ parseQueryString(queryString) {
1761
+ if (!queryString) return {};
1762
+
1763
+ const params = {};
1764
+ queryString.split('&').forEach(param => {
1765
+ const [key, value] = param.split('=');
1766
+ params[decodeURIComponent(key)] = decodeURIComponent(value || '');
1767
+ });
1768
+ return params;
1769
+ }
1770
+
1771
+ /**
1772
+ * Navigue vers une route
1773
+ * @param {string} path - Chemin de destination (ex: '/user/123', '/posts/tech/456?sort=date')
1774
+ * @param {Object} options - Options de navigation
1775
+ */
1776
+ navigate(path, options = {}) {
1777
+ this.navigateTo(path, options);
1778
+ }
1779
+
1780
+ /**
1781
+ * Méthode interne de navigation
1782
+ * @private
1783
+ */
1784
+ async navigateTo(path, options = {}) {
1785
+ const {
1786
+ replace = false,
1787
+ animate = true,
1788
+ direction = 'forward',
1789
+ transition = null,
1790
+ state = {}
1791
+ } = options;
1792
+
1793
+ const match = this.matchRoute(path);
1794
+ if (!match) {
1795
+ console.warn(`Route not found: ${path}`);
1796
+ return;
1797
+ }
1798
+
1799
+ const { route, params, query, pathname } = match;
1800
+
1801
+ // ===== LIFECYCLE: AVANT DE QUITTER L'ANCIENNE ROUTE =====
1802
+ const currentRouteData = this.routes.get(this.currentRoute);
1803
+ if (currentRouteData?.beforeLeave) {
1804
+ const canLeave = await currentRouteData.beforeLeave(this.currentParams, this.currentQuery, this);
1805
+ if (canLeave === false) {
1806
+ console.log('Navigation cancelled by beforeLeave hook');
1807
+ return;
1808
+ }
1809
+ }
1810
+
1811
+ if (currentRouteData?.onLeave) {
1812
+ await currentRouteData.onLeave(this.currentParams, this.currentQuery, this);
1813
+ }
1814
+
1815
+ // ===== LIFECYCLE: AVANT D'ENTRER DANS LA NOUVELLE ROUTE =====
1816
+ if (route.beforeEnter) {
1817
+ const canEnter = await route.beforeEnter(params, query, this);
1818
+ if (canEnter === false) {
1819
+ console.log('Navigation cancelled by beforeEnter hook');
1820
+ return;
1821
+ }
1822
+ }
1823
+
1824
+ if (route.onEnter) {
1825
+ await route.onEnter(params, query, this);
1826
+ }
1827
+
1828
+ // ===== SAUVEGARDER L'ÉTAT ACTUEL =====
1829
+ const oldComponents = [...this.components];
1830
+ const oldRoute = this.currentRoute;
1831
+ const oldParams = { ...this.currentParams };
1832
+ const oldQuery = { ...this.currentQuery };
1833
+
1834
+ // ✅ CRUCIAL : ARRÊTER LES ANCIENNES CAMÉRAS AVANT DE CRÉER LES NOUVELLES
1835
+ oldComponents.forEach(comp => {
1836
+ if (comp.constructor.name === 'FloatedCamera' ||
1837
+ comp.constructor.name === 'Camera' ||
1838
+ comp.constructor.name === 'Video' ||
1839
+ comp.constructor.name === 'QRCodeReader') {
1840
+ if (comp.stopCamera && typeof comp.stopCamera === 'function') {
1841
+ comp.stopCamera();
1842
+ }
1843
+ // Destroy le composant pour être sûr
1844
+ if (comp.destroy && typeof comp.destroy === 'function') {
1845
+ comp.destroy();
1846
+ }
1847
+ }
1848
+ });
1849
+
1850
+ // ✅ Nettoyer toutes les vidéos orphelines AVANT de créer les nouveaux composants
1851
+ const allVideos = document.querySelectorAll('video');
1852
+ allVideos.forEach(v => v.remove());
1853
+
1854
+ // ===== METTRE À JOUR L'ÉTAT =====
1855
+ this.currentRoute = pathname;
1856
+ this.currentParams = params;
1857
+ this.currentQuery = query;
1858
+
1859
+ // ===== GÉRER L'HISTORIQUE =====
1860
+ if (!replace) {
1861
+ this.historyIndex++;
1862
+ this.history = this.history.slice(0, this.historyIndex);
1863
+ this.history.push({ path, params, query, state });
1864
+ window.history.pushState({ route: path, params, query, state }, '', path);
1865
+ } else {
1866
+ this.history[this.historyIndex] = { path, params, query, state };
1867
+ window.history.replaceState({ route: path, params, query, state }, '', path);
1868
+ }
1869
+
1870
+ // ===== CRÉER LES NOUVEAUX COMPOSANTS =====
1871
+ this.components = [];
1872
+ if (typeof route.component === 'function') {
1873
+ route.component(this, params, query);
1874
+ }
1875
+
1876
+ // ===== LANCER L'ANIMATION DE TRANSITION =====
1877
+ if (animate && !this.transitionState.isTransitioning) {
1878
+ const transitionType = transition || route.transition || 'slide';
1879
+ this.startTransition(oldComponents, this.components, transitionType, direction);
1880
+ }
1881
+
1882
+ // ===== LIFECYCLE: APRÈS ÊTRE ENTRÉ DANS LA NOUVELLE ROUTE =====
1883
+ if (route.afterEnter) {
1884
+ route.afterEnter(params, query, this);
1885
+ }
1886
+
1887
+ if (currentRouteData?.afterLeave) {
1888
+ if (animate && this.transitionState.isTransitioning) {
1889
+ setTimeout(() => {
1890
+ currentRouteData.afterLeave(oldParams, oldQuery, this);
1891
+ }, this.transitionState.duration || 300);
1892
+ } else {
1893
+ currentRouteData.afterLeave(oldParams, oldQuery, this);
1894
+ }
1895
+ }
1896
+
1897
+ this._maxScrollDirty = true;
1898
+ }
1899
+
1900
+ /**
1901
+ * Démarre une animation de transition
1902
+ * @private
1903
+ */
1904
+ startTransition(oldComponents, newComponents, type, direction) {
1905
+ this.transitionState = {
1906
+ isTransitioning: true,
1907
+ progress: 0,
1908
+ duration: 300,
1909
+ type,
1910
+ direction,
1911
+ oldComponents: [...oldComponents],
1912
+ newComponents: [...newComponents],
1913
+ startTime: Date.now()
1914
+ };
1915
+ }
1916
+
1917
+ /**
1918
+ * Met à jour l'animation de transition
1919
+ * @private
1920
+ */
1921
+ updateTransition() {
1922
+ if (!this.transitionState.isTransitioning) return;
1923
+
1924
+ const elapsed = Date.now() - this.transitionState.startTime;
1925
+ this.transitionState.progress = Math.min(elapsed / this.transitionState.duration, 1);
1926
+
1927
+ // Fonction d'easing (ease-in-out)
1928
+ const eased = this.easeInOutCubic(this.transitionState.progress);
1929
+
1930
+ // Appliquer la transformation selon le type
1931
+ this.ctx.save();
1932
+ this.applyTransitionTransform(eased);
1933
+ this.ctx.restore();
1934
+
1935
+ // Terminer la transition
1936
+ if (this.transitionState.progress >= 1) {
1937
+ this.transitionState.isTransitioning = false;
1938
+ this.transitionState.oldComponents = [];
1939
+ }
1940
+ }
1941
+
1942
+ /**
1943
+ * Applique la transformation de transition
1944
+ * @private
1945
+ */
1946
+ applyTransitionTransform(progress) {
1947
+ const {
1948
+ type,
1949
+ direction,
1950
+ oldComponents,
1951
+ newComponents
1952
+ } = this.transitionState;
1953
+ const directionMultiplier = direction === 'forward' ? 1 : -1;
1954
+
1955
+ switch (type) {
1956
+ case 'slide':
1957
+ // Dessiner l'ancienne vue qui sort
1958
+ this.ctx.save();
1959
+ this.ctx.translate(-this.width * progress * directionMultiplier, 0);
1960
+ this.ctx.globalAlpha = 1 - progress * 0.3;
1961
+ for (let comp of oldComponents) {
1962
+ if (comp.visible) comp.draw(this.ctx);
1963
+ }
1964
+ this.ctx.restore();
1965
+
1966
+ // Dessiner la nouvelle vue qui entre
1967
+ this.ctx.save();
1968
+ this.ctx.translate(this.width * (1 - progress) * directionMultiplier, 0);
1969
+ for (let comp of newComponents) {
1970
+ if (comp.visible) comp.draw(this.ctx);
1971
+ }
1972
+ this.ctx.restore();
1973
+ break;
1974
+
1975
+ case 'fade':
1976
+ // Dessiner l'ancienne vue qui fade out
1977
+ this.ctx.save();
1978
+ this.ctx.globalAlpha = 1 - progress;
1979
+ for (let comp of oldComponents) {
1980
+ if (comp.visible) comp.draw(this.ctx);
1981
+ }
1982
+ this.ctx.restore();
1983
+
1984
+ // Dessiner la nouvelle vue qui fade in
1985
+ this.ctx.save();
1986
+ this.ctx.globalAlpha = progress;
1987
+ for (let comp of newComponents) {
1988
+ if (comp.visible) comp.draw(this.ctx);
1989
+ }
1990
+ this.ctx.restore();
1991
+ break;
1992
+
1993
+ case 'none':
1994
+ // Pas d'animation, juste afficher la nouvelle vue
1995
+ for (let comp of newComponents) {
1996
+ if (comp.visible) comp.draw(this.ctx);
1997
+ }
1998
+ break;
1999
+ }
2000
+ }
2001
+
2002
+ /**
2003
+ * Fonction d'easing
2004
+ * @private
2005
+ */
2006
+ easeInOutCubic(t) {
2007
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
2008
+ }
2009
+
2010
+ /**
2011
+ * Retour en arrière dans l'historique
2012
+ */
2013
+ goBack() {
2014
+ if (this.historyIndex > 0) {
2015
+ this.historyIndex--;
2016
+ const historyEntry = this.history[this.historyIndex];
2017
+ this.navigateTo(historyEntry.path, {
2018
+ replace: true,
2019
+ animate: true,
2020
+ direction: 'back'
2021
+ });
2022
+ window.history.back();
2023
+ }
2024
+ }
2025
+
2026
+ /**
2027
+ * Avancer dans l'historique
2028
+ */
2029
+ goForward() {
2030
+ if (this.historyIndex < this.history.length - 1) {
2031
+ this.historyIndex++;
2032
+ const historyEntry = this.history[this.historyIndex];
2033
+ this.navigateTo(historyEntry.path, {
2034
+ replace: true,
2035
+ animate: true,
2036
+ direction: 'forward'
2037
+ });
2038
+ window.history.forward();
2039
+ }
2040
+ }
2041
+
2042
+ /**
2043
+ * Obtient les paramètres de la route actuelle
2044
+ * @returns {Object}
2045
+ */
2046
+ getParams() {
2047
+ return {
2048
+ ...this.currentParams
2049
+ };
2050
+ }
2051
+
2052
+ /**
2053
+ * Obtient la query string de la route actuelle
2054
+ * @returns {Object}
2055
+ */
2056
+ getQuery() {
2057
+ return {
2058
+ ...this.currentQuery
2059
+ };
2060
+ }
2061
+
2062
+ /**
2063
+ * Obtient un paramètre spécifique
2064
+ * @param {string} name
2065
+ * @returns {string|undefined}
2066
+ */
2067
+ getParam(name) {
2068
+ return this.currentParams[name];
2069
+ }
2070
+
2071
+ /**
2072
+ * Obtient un paramètre de query spécifique
2073
+ * @param {string} name
2074
+ * @returns {string|undefined}
2075
+ */
2076
+ getQueryParam(name) {
2077
+ return this.currentQuery[name];
2078
+ }
2079
+
2080
+ // ===== FIN DES MÉTHODES DE ROUTING =====
2081
+
2082
+ handleTouchStart(e) {
2083
+ e.preventDefault();
2084
+ this.isDragging = false;
2085
+ const touch = e.touches[0];
2086
+ const pos = this.getTouchPos(touch);
2087
+ this.lastTouchY = pos.y;
2088
+
2089
+ // Informer le Worker
2090
+ this.scrollWorker.postMessage({
2091
+ type: 'SET_DRAGGING',
2092
+ payload: {
2093
+ isDragging: false,
2094
+ lastTouchY: this.lastTouchY
2095
+ }
2096
+ });
2097
+
2098
+ this.checkComponentsAtPosition(pos.x, pos.y, 'start');
2099
+ }
2100
+
2101
+ handleTouchMove(e) {
2102
+ e.preventDefault();
2103
+ const touch = e.touches[0];
2104
+ const pos = this.getTouchPos(touch);
2105
+
2106
+ if (!this.isDragging) {
2107
+ const deltaY = Math.abs(pos.y - this.lastTouchY);
2108
+ if (deltaY > 5) {
2109
+ this.isDragging = true;
2110
+ this.scrollWorker.postMessage({
2111
+ type: 'SET_DRAGGING',
2112
+ payload: {
2113
+ isDragging: true,
2114
+ lastTouchY: this.lastTouchY
2115
+ }
2116
+ });
2117
+ }
2118
+ }
2119
+
2120
+ if (this.isDragging) {
2121
+ const deltaY = pos.y - this.lastTouchY;
2122
+
2123
+ // Déléguer le calcul au Worker
2124
+ this.scrollWorker.postMessage({
2125
+ type: 'HANDLE_TOUCH_MOVE',
2126
+ payload: {
2127
+ deltaY
2128
+ }
2129
+ });
2130
+
2131
+ this.lastTouchY = pos.y;
2132
+ } else {
2133
+ this.checkComponentsAtPosition(pos.x, pos.y, 'move');
2134
+ }
2135
+ }
2136
+
2137
+ handleTouchEnd(e) {
2138
+ e.preventDefault();
2139
+ const touch = e.changedTouches[0];
2140
+ const pos = this.getTouchPos(touch);
2141
+
2142
+ if (!this.isDragging) {
2143
+ this.checkComponentsAtPosition(pos.x, pos.y, 'end');
2144
+ } else {
2145
+ this.isDragging = false;
2146
+ this.scrollWorker.postMessage({
2147
+ type: 'SET_DRAGGING',
2148
+ payload: {
2149
+ isDragging: false,
2150
+ lastVelocity: this.scrollVelocity
2151
+ }
2152
+ });
2153
+ }
2154
+ }
2155
+
2156
+ handleMouseDown(e) {
2157
+ this.isDragging = false;
2158
+ this.lastTouchY = e.clientY;
2159
+
2160
+ this.scrollWorker.postMessage({
2161
+ type: 'SET_DRAGGING',
2162
+ payload: {
2163
+ isDragging: false,
2164
+ lastTouchY: this.lastTouchY
2165
+ }
2166
+ });
2167
+
2168
+ this.checkComponentsAtPosition(e.clientX, e.clientY, 'start');
2169
+ }
2170
+
2171
+ handleMouseMove(e) {
2172
+ if (!this.isDragging) {
2173
+ const deltaY = Math.abs(e.clientY - this.lastTouchY);
2174
+ if (deltaY > 5) {
2175
+ this.isDragging = true;
2176
+ this.scrollWorker.postMessage({
2177
+ type: 'SET_DRAGGING',
2178
+ payload: {
2179
+ isDragging: true,
2180
+ lastTouchY: this.lastTouchY
2181
+ }
2182
+ });
2183
+ }
2184
+ }
2185
+
2186
+ if (this.isDragging) {
2187
+ const deltaY = e.clientY - this.lastTouchY;
2188
+
2189
+ this.scrollWorker.postMessage({
2190
+ type: 'HANDLE_TOUCH_MOVE',
2191
+ payload: {
2192
+ deltaY
2193
+ }
2194
+ });
2195
+
2196
+ this.lastTouchY = e.clientY;
2197
+ } else {
2198
+ this.checkComponentsAtPosition(e.clientX, e.clientY, 'move');
2199
+ }
2200
+ }
2201
+
2202
+ handleMouseUp(e) {
2203
+ if (!this.isDragging) {
2204
+ this.checkComponentsAtPosition(e.clientX, e.clientY, 'end');
2205
+ } else {
2206
+ this.isDragging = false;
2207
+ this.scrollWorker.postMessage({
2208
+ type: 'SET_DRAGGING',
2209
+ payload: {
2210
+ isDragging: false,
2211
+ lastVelocity: this.scrollVelocity
2212
+ }
2213
+ });
2214
+ }
2215
+ }
2216
+
2217
+
2218
+ getTouchPos(touch) {
2219
+ const rect = this.canvas.getBoundingClientRect();
2220
+ return {
2221
+ x: touch.clientX - rect.left,
2222
+ y: touch.clientY - rect.top
2223
+ };
2224
+ }
2225
+
2226
+ checkComponentsAtPosition(x, y, eventType) {
2227
+ const isFixedComponent = (comp) =>
2228
+ FIXED_COMPONENT_TYPES.has(comp.constructor);
2229
+
2230
+ for (let i = this.components.length - 1; i >= 0; i--) {
2231
+ const comp = this.components[i];
2232
+
2233
+ if (comp.visible) {
2234
+ const adjustedY = isFixedComponent(comp) ? y : y - this.scrollOffset;
2235
+
2236
+ if (comp instanceof Card && comp.clickableChildren && comp.children && comp.children.length > 0) {
2237
+ if (comp.isPointInside(x, adjustedY)) {
2238
+ const cardAdjustedY = adjustedY - comp.y - comp.padding;
2239
+ const cardAdjustedX = x - comp.x - comp.padding;
2240
+
2241
+ for (let j = comp.children.length - 1; j >= 0; j--) {
2242
+ const child = comp.children[j];
2243
+
2244
+ if (child.visible &&
2245
+ cardAdjustedY >= child.y &&
2246
+ cardAdjustedY <= child.y + child.height &&
2247
+ cardAdjustedX >= child.x &&
2248
+ cardAdjustedX <= child.x + child.width) {
2249
+
2250
+ const relativeX = cardAdjustedX - child.x;
2251
+ const relativeY = cardAdjustedY - child.y;
2252
+
2253
+ switch (eventType) {
2254
+ case 'start':
2255
+ child.pressed = true;
2256
+ if (child.onPress) child.onPress?.(relativeX, relativeY);
2257
+ break;
2258
+
2259
+ case 'move':
2260
+ if (!child.hovered) {
2261
+ child.hovered = true;
2262
+ if (child.onHover) child.onHover();
2263
+ }
2264
+ if (child.onMove) child.onMove?.(relativeX, relativeY);
2265
+ break;
2266
+
2267
+ case 'end':
2268
+ if (child.pressed) {
2269
+ child.pressed = false;
2270
+
2271
+ if (child instanceof Input || child instanceof PasswordInput || child instanceof InputTags || child instanceof InputDatalist) {
2272
+ for (let other of this.components) {
2273
+ if (
2274
+ (other instanceof Input ||
2275
+ other instanceof PasswordInput ||
2276
+ other instanceof InputTags ||
2277
+ other instanceof InputDatalist) &&
2278
+ other !== child &&
2279
+ other.focused
2280
+ ) {
2281
+ other.focused = false;
2282
+ other.cursorVisible = false;
2283
+ other.onBlur?.();
2284
+ }
2285
+ }
2286
+
2287
+ child.focused = true;
2288
+ child.cursorVisible = true;
2289
+ if (child.onFocus) child.onFocus();
2290
+ } else if (child.onClick) {
2291
+ child.onClick();
2292
+ } else if (child.onPress) {
2293
+ child.onPress?.(relativeX, relativeY);
2294
+ }
2295
+ }
2296
+ break;
2297
+ }
2298
+
2299
+ return;
2300
+ }
2301
+ }
2302
+ }
2303
+ }
2304
+
2305
+ if (comp.isPointInside(x, adjustedY)) {
2306
+ switch (eventType) {
2307
+ case 'start':
2308
+ comp.pressed = true;
2309
+ if (comp.onPress) comp.onPress(x, adjustedY);
2310
+ break;
2311
+
2312
+ case 'move':
2313
+ if (!comp.hovered) {
2314
+ comp.hovered = true;
2315
+ if (comp.onHover) comp.onHover();
2316
+ }
2317
+ if (comp.onMove) comp.onMove(x, adjustedY);
2318
+ break;
2319
+
2320
+ case 'end':
2321
+ if (comp.pressed) {
2322
+ comp.pressed = false;
2323
+
2324
+ if (comp instanceof Input || comp instanceof PasswordInput || comp instanceof InputTags || comp instanceof InputDatalist) {
2325
+ for (let other of this.components) {
2326
+ if (
2327
+ (other instanceof Input ||
2328
+ other instanceof PasswordInput ||
2329
+ other instanceof InputTags ||
2330
+ other instanceof InputDatalist) &&
2331
+ other !== comp &&
2332
+ other.focused
2333
+ ) {
2334
+ other.focused = false;
2335
+ other.cursorVisible = false;
2336
+ other.onBlur?.();
2337
+ }
2338
+ }
2339
+
2340
+ comp.focused = true;
2341
+ comp.cursorVisible = true;
2342
+ if (comp.onFocus) comp.onFocus();
2343
+ } else if (comp.onClick) {
2344
+ comp.onClick();
2345
+ } else if (comp.onPress) {
2346
+ comp.onPress(x, adjustedY);
2347
+ }
2348
+ }
2349
+ break;
2350
+ }
2351
+
2352
+ return;
2353
+ } else {
2354
+ comp.hovered = false;
2355
+ }
2356
+ }
2357
+ }
2358
+ }
2359
+
2360
+ getMaxScroll() {
2361
+ // Utiliser le cache du Worker
2362
+ if (!this._maxScrollDirty && this._cachedMaxScroll !== undefined) {
2363
+ return this._cachedMaxScroll;
2364
+ }
2365
+
2366
+ // Fallback si le Worker n'est pas encore initialisé
2367
+ let maxY = 0;
2368
+ for (const comp of this.components) {
2369
+ if (this.isFixedComponent(comp) || !comp.visible) continue;
2370
+ const bottom = comp.y + comp.height;
2371
+ if (bottom > maxY) maxY = bottom;
2372
+ }
2373
+
2374
+ return Math.max(0, maxY - this.height + 50);
2375
+ }
2376
+
2377
+ /*getMaxScroll() {
2378
+ let maxY = 0;
2379
+ for (let comp of this.components) {
2380
+ if (!this.isFixedComponent(comp)) {
2381
+ maxY = Math.max(maxY, comp.y + comp.height);
2382
+ }
2383
+ }
2384
+ return Math.max(0, maxY - this.height + 50);
2385
+ }*/
2386
+
2387
+ handleResize() {
2388
+ if (this.resizeTimeout) clearTimeout(this.resizeTimeout);
2389
+
2390
+ this.resizeTimeout = setTimeout(() => {
2391
+ const newWidth = window.innerWidth;
2392
+ const newHeight = window.innerHeight;
2393
+
2394
+ // Règle clé : on resize UNIQUEMENT si la largeur a vraiment changé
2395
+ // (rotation, split-screen, vraie fenêtre changée)
2396
+ // À la fermeture du clavier → largeur = identique → on skip
2397
+ if (Math.abs(newWidth - this.width) <= 8) { // tolérance pixels (scrollbar, bordures...)
2398
+ console.log("[resize] Largeur identique → probablement clavier (open/close) → ignoré");
2399
+ return;
2400
+ }
2401
+
2402
+ console.log("[resize] Largeur changée → vrai resize");
2403
+
2404
+ this.width = newWidth;
2405
+ this.height = newHeight;
2406
+
2407
+ this.setupCanvas(); // ← change canvas.width/height + DPR + style
2408
+ this._maxScrollDirty = true;
2409
+
2410
+ if (this.scrollWorker) {
2411
+ this.scrollWorker.postMessage({
2412
+ type: 'UPDATE_DIMENSIONS',
2413
+ payload: { height: this.height }
2414
+ });
2415
+ }
2416
+
2417
+ this.components.forEach(comp => comp.onResize?.(this.width, this.height));
2418
+
2419
+ }, 100); // 80–120 ms
2420
+ }
2421
+
2422
+ /*handleResize() {
2423
+ if (this.resizeTimeout) clearTimeout(this.resizeTimeout); // ✅ AJOUTER
2424
+
2425
+ this.resizeTimeout = setTimeout(() => { // ✅ AJOUTER
2426
+ if (!this.useWebGL) {
2427
+ this.width = window.innerWidth;
2428
+ this.height = window.innerHeight;
2429
+ this.setupCanvas();
2430
+
2431
+ for (const comp of this.components) {
2432
+ if (comp._resize) {
2433
+ comp._resize(this.width, this.height);
2434
+ }
2435
+ }
2436
+ this._maxScrollDirty = true; // ✅ AJOUTER
2437
+ }
2438
+ }, 150); // ✅ AJOUTER (throttle 150ms)
2439
+ }*/
2440
+
2441
+ add(component) {
2442
+ this.components.push(component);
2443
+ component._mount();
2444
+ this.updateScrollWorkerComponents();
2445
+ return component;
2446
+ }
2447
+
2448
+ remove(component) {
2449
+ const index = this.components.indexOf(component);
2450
+ if (index > -1) {
2451
+ component._unmount();
2452
+ this.components.splice(index, 1);
2453
+ this.updateScrollWorkerComponents();
2454
+ }
2455
+ }
2456
+
2457
+ // 4. Modifiez markComponentDirty() pour éviter de marquer pendant le scroll
2458
+ markComponentDirty(component) {
2459
+ // Vérifications basiques
2460
+ if (!component || !component.visible) return;
2461
+
2462
+ // ✅ TOUJOURS ajouter au set des composants sales
2463
+ // Les conditions de rendu seront vérifiées dans _renderDirtyComponents
2464
+ if (this.optimizationEnabled) {
2465
+ this.dirtyComponents.add(component);
2466
+ }
2467
+
2468
+ // ✅ Optionnel : Marquer aussi le composant lui-même
2469
+ if (component._dirty !== undefined) {
2470
+ component._dirty = true;
2471
+ }
2472
+ }
2473
+
2474
+ enableOptimization() {
2475
+ this.optimizationEnabled = true;
2476
+ }
2477
+
2478
+ /**
2479
+ * Dessine un petit triangle rouge pour indiquer overflow (style Flutter)
2480
+ */
2481
+ drawOverflowIndicators() {
2482
+ const ctx = this.ctx;
2483
+
2484
+ // Pour chaque composant
2485
+ for (let comp of this.components) {
2486
+ if (!comp.visible) continue;
2487
+
2488
+ // Position réelle à l'écran
2489
+ const isFixed = this.isFixedComponent(comp);
2490
+ const screenY = isFixed ? comp.y : comp.y + this.scrollOffset;
2491
+ const screenX = comp.x;
2492
+
2493
+ // Vérifier si le composant TEXT a une largeur/hauteur incorrecte
2494
+ let actualWidth = comp.width;
2495
+ let actualHeight = comp.height;
2496
+
2497
+ // Si c'est un Text, vérifier la taille réelle du texte
2498
+ if (comp instanceof Text && comp.text && ctx.measureText) {
2499
+ try {
2500
+ // Sauvegarder le style actuel
2501
+ ctx.save();
2502
+
2503
+ // Appliquer le style du texte
2504
+ if (comp.fontSize) {
2505
+ ctx.font = `${comp.fontSize}px ${comp.fontFamily || 'Arial'}`;
2506
+ }
2507
+
2508
+ // Mesurer la taille réelle
2509
+ const metrics = ctx.measureText(comp.text);
2510
+ actualWidth = metrics.width + (comp.padding || 0) * 2;
2511
+ actualHeight = (comp.fontSize || 16) + (comp.padding || 0) * 2;
2512
+
2513
+ ctx.restore();
2514
+ } catch (e) {
2515
+ // En cas d'erreur, garder les dimensions par défaut
2516
+ }
2517
+ }
2518
+
2519
+ // Calculer les limites RÉELLES du composant
2520
+ const compLeft = screenX;
2521
+ const compRight = screenX + actualWidth;
2522
+ const compTop = screenY;
2523
+ const compBottom = screenY + actualHeight;
2524
+
2525
+ // Vérifier les débordements avec les dimensions RÉELLES
2526
+ const overflow = {
2527
+ left: compLeft < 0,
2528
+ right: compRight > this.width,
2529
+ top: compTop < 0,
2530
+ bottom: compBottom > this.height
2531
+ };
2532
+
2533
+ // Si aucun débordement, passer au suivant
2534
+ if (!overflow.left && !overflow.right && !overflow.top && !overflow.bottom) {
2535
+ continue;
2536
+ }
2537
+
2538
+ // DEBUG: Afficher les infos du composant
2539
+ if (this.debbug) {
2540
+ console.table({
2541
+ type: comp.constructor?.name,
2542
+ x: comp.x,
2543
+ y: comp.y,
2544
+ declaredSize: `${comp.width}x${comp.height}`,
2545
+ actualSize: `${actualWidth}x${actualHeight}`,
2546
+ screenPos: `(${screenX}, ${screenY})`,
2547
+ overflow
2548
+ });
2549
+ }
2550
+
2551
+ // Dessiner les indicateurs
2552
+ ctx.save();
2553
+
2554
+ // 1. Bordures rouges sur les parties qui débordent
2555
+ ctx.strokeStyle = 'red';
2556
+ ctx.lineWidth = 2;
2557
+ ctx.fillStyle = 'rgba(255, 0, 0, 0.2)';
2558
+
2559
+ // Gauche
2560
+ if (overflow.left) {
2561
+ const overflowWidth = Math.min(actualWidth, -compLeft);
2562
+ ctx.fillRect(compLeft, compTop, overflowWidth, actualHeight);
2563
+ ctx.strokeRect(compLeft, compTop, overflowWidth, actualHeight);
2564
+ }
2565
+
2566
+ // Droite
2567
+ if (overflow.right) {
2568
+ const overflowStart = Math.max(0, this.width - compLeft);
2569
+ const overflowWidth = Math.min(actualWidth, compRight - this.width);
2570
+ ctx.fillRect(this.width - overflowWidth, compTop, overflowWidth, actualHeight);
2571
+ ctx.strokeRect(this.width - overflowWidth, compTop, overflowWidth, actualHeight);
2572
+ }
2573
+
2574
+ // Haut
2575
+ if (overflow.top) {
2576
+ const overflowHeight = Math.min(actualHeight, -compTop);
2577
+ ctx.fillRect(compLeft, compTop, actualWidth, overflowHeight);
2578
+ ctx.strokeRect(compLeft, compTop, actualWidth, overflowHeight);
2579
+ }
2580
+
2581
+ // Bas
2582
+ if (overflow.bottom) {
2583
+ const overflowStart = Math.max(0, this.height - compTop);
2584
+ const overflowHeight = Math.min(actualHeight, compBottom - this.height);
2585
+ ctx.fillRect(compLeft, this.height - overflowHeight, actualWidth, overflowHeight);
2586
+ ctx.strokeRect(compLeft, this.height - overflowHeight, actualWidth, overflowHeight);
2587
+ }
2588
+
2589
+ // 2. Points rouges aux coins
2590
+ ctx.fillStyle = 'red';
2591
+ const markerSize = 6;
2592
+
2593
+ // Coin supérieur gauche
2594
+ if (overflow.left || overflow.top) {
2595
+ ctx.fillRect(compLeft, compTop, markerSize, markerSize);
2596
+ }
2597
+
2598
+ // Coin supérieur droit
2599
+ if (overflow.right || overflow.top) {
2600
+ ctx.fillRect(compRight - markerSize, compTop, markerSize, markerSize);
2601
+ }
2602
+
2603
+ // Coin inférieur gauche
2604
+ if (overflow.left || overflow.bottom) {
2605
+ ctx.fillRect(compLeft, compBottom - markerSize, markerSize, markerSize);
2606
+ }
2607
+
2608
+ // Coin inférieur droit
2609
+ if (overflow.right || overflow.bottom) {
2610
+ ctx.fillRect(compRight - markerSize, compBottom - markerSize, markerSize, markerSize);
2611
+ }
2612
+
2613
+ // 3. Texte d'information (optionnel)
2614
+ if (this.debbug && comp.text) {
2615
+ ctx.fillStyle = 'red';
2616
+ ctx.font = '10px monospace';
2617
+ ctx.textAlign = 'left';
2618
+
2619
+ const overflowText = [];
2620
+ if (overflow.left) overflowText.push('←');
2621
+ if (overflow.right) overflowText.push('→');
2622
+ if (overflow.top) overflowText.push('↑');
2623
+ if (overflow.bottom) overflowText.push('↓');
2624
+
2625
+ if (overflowText.length > 0) {
2626
+ ctx.fillText(
2627
+ `"${comp.text.substring(0, 10)}${comp.text.length > 10 ? '...' : ''}" ${overflowText.join('')}`,
2628
+ compLeft + 5,
2629
+ compTop - 5
2630
+ );
2631
+ }
2632
+ }
2633
+
2634
+ ctx.restore();
2635
+ }
2636
+ }
2637
+
2638
+ startRenderLoop() {
2639
+ let lastScrollOffset = this.scrollOffset;
2640
+
2641
+ const render = () => {
2642
+ if (!this._splashFinished) {
2643
+ requestAnimationFrame(render);
2644
+ return;
2645
+ }
2646
+
2647
+ // TOUJOURS effacer l'écran en premier
2648
+ this.ctx.fillStyle = this.backgroundColor || '#ffffff';
2649
+ this.ctx.fillRect(0, 0, this.width, this.height);
2650
+
2651
+ // Si une transition est en cours
2652
+ if (this.transitionState.isTransitioning) {
2653
+ // Mettre à jour la progression
2654
+ const elapsed = Date.now() - this.transitionState.startTime;
2655
+ this.transitionState.progress = Math.min(elapsed / this.transitionState.duration, 1);
2656
+
2657
+ // Rendu spécial pour la transition
2658
+ this.renderSimpleTransition();
2659
+
2660
+ // Si la transition est terminée
2661
+ if (this.transitionState.progress >= 1) {
2662
+ this.transitionState.isTransitioning = false;
2663
+ this.transitionState.oldComponents = [];
2664
+ }
2665
+ }
2666
+ // Sinon, rendu normal
2667
+ else {
2668
+ this.renderFull();
2669
+ }
2670
+
2671
+ // Calcul FPS (optionnel)
2672
+ this._frames++;
2673
+ const now = performance.now();
2674
+ const elapsed = now - this._lastFpsTime;
2675
+
2676
+ if (elapsed >= 1000) {
2677
+ this.fps = Math.round((this._frames * 1000) / elapsed);
2678
+ this._frames = 0;
2679
+ this._lastFpsTime = now;
2680
+ }
2681
+
2682
+ if (this.showFps) {
2683
+ this.ctx.save();
2684
+ this.ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
2685
+ this.ctx.fillRect(10, 10, 100, 40);
2686
+ this.ctx.fillStyle = '#00ff00';
2687
+ this.ctx.font = 'bold 20px monospace';
2688
+ this.ctx.textAlign = 'left';
2689
+ this.ctx.textBaseline = 'top';
2690
+ this.ctx.fillText(`FPS: ${this.fps}`, 20, 20);
2691
+ this.ctx.restore();
2692
+ }
2693
+
2694
+ // Mettre à jour l'inertie du scroll
2695
+ if (Math.abs(this.scrollVelocity) > 0.1 && !this.isDragging) {
2696
+ this.scrollWorker.postMessage({ type: 'UPDATE_INERTIA' });
2697
+ }
2698
+
2699
+ requestAnimationFrame(render);
2700
+ };
2701
+
2702
+ render();
2703
+ }
2704
+
2705
+ /**
2706
+ * Rendu ultra simple pour la transition slide
2707
+ */
2708
+ renderSimpleTransition() {
2709
+ const { progress, type, direction, oldComponents, newComponents } = this.transitionState;
2710
+
2711
+ // Easing pour une animation plus fluide
2712
+ const eased = this.easeInOutCubic(progress);
2713
+
2714
+ const directionMultiplier = direction === 'forward' ? 1 : -1;
2715
+
2716
+ if (type === 'slide') {
2717
+ // ✅ AMÉLIORATION 1 : Dessiner l'ANCIENNE vue qui sort
2718
+ this.ctx.save();
2719
+
2720
+ // L'ancienne vue se déplace vers la gauche/droite
2721
+ const oldOffset = -this.width * eased * directionMultiplier;
2722
+ this.ctx.translate(oldOffset, 0);
2723
+
2724
+ // Légère transparence pour donner de la profondeur
2725
+ this.ctx.globalAlpha = 1 - (eased * 0.3);
2726
+
2727
+ // Dessiner les anciens composants
2728
+ for (let comp of oldComponents) {
2729
+ if (comp && comp.visible) {
2730
+ const isFixed = this.isFixedComponent(comp);
2731
+
2732
+ if (isFixed) {
2733
+ comp.draw(this.ctx);
2734
+ } else {
2735
+ this.ctx.save();
2736
+ this.ctx.translate(0, 0); // Pas de scroll pendant la transition
2737
+ comp.draw(this.ctx);
2738
+ this.ctx.restore();
2739
+ }
2740
+ }
2741
+ }
2742
+
2743
+ this.ctx.restore();
2744
+
2745
+ // ✅ AMÉLIORATION 2 : Dessiner la NOUVELLE vue qui entre
2746
+ this.ctx.save();
2747
+
2748
+ // La nouvelle vue arrive de la droite/gauche
2749
+ const newOffset = this.width * (1 - eased) * directionMultiplier;
2750
+ this.ctx.translate(newOffset, 0);
2751
+
2752
+ // Dessiner les nouveaux composants
2753
+ for (let comp of newComponents) {
2754
+ if (comp && comp.visible) {
2755
+ const isFixed = this.isFixedComponent(comp);
2756
+
2757
+ if (isFixed) {
2758
+ comp.draw(this.ctx);
2759
+ } else {
2760
+ this.ctx.save();
2761
+ this.ctx.translate(0, 0); // Pas de scroll pendant la transition
2762
+ comp.draw(this.ctx);
2763
+ this.ctx.restore();
2764
+ }
2765
+ }
2766
+ }
2767
+
2768
+ this.ctx.restore();
2769
+ }
2770
+ else if (type === 'fade') {
2771
+ // Ancienne vue qui fade out
2772
+ this.ctx.save();
2773
+ this.ctx.globalAlpha = 1 - eased;
2774
+
2775
+ for (let comp of oldComponents) {
2776
+ if (comp && comp.visible) {
2777
+ const isFixed = this.isFixedComponent(comp);
2778
+ if (isFixed) {
2779
+ comp.draw(this.ctx);
2780
+ } else {
2781
+ this.ctx.save();
2782
+ this.ctx.translate(0, 0);
2783
+ comp.draw(this.ctx);
2784
+ this.ctx.restore();
2785
+ }
2786
+ }
2787
+ }
2788
+
2789
+ this.ctx.restore();
2790
+
2791
+ // Nouvelle vue qui fade in
2792
+ this.ctx.save();
2793
+ this.ctx.globalAlpha = eased;
2794
+
2795
+ for (let comp of newComponents) {
2796
+ if (comp && comp.visible) {
2797
+ const isFixed = this.isFixedComponent(comp);
2798
+ if (isFixed) {
2799
+ comp.draw(this.ctx);
2800
+ } else {
2801
+ this.ctx.save();
2802
+ this.ctx.translate(0, 0);
2803
+ comp.draw(this.ctx);
2804
+ this.ctx.restore();
2805
+ }
2806
+ }
2807
+ }
2808
+
2809
+ this.ctx.restore();
2810
+ }
2811
+ else {
2812
+ this.renderFull();
2813
+ }
2814
+ }
2815
+
2816
+ /**
2817
+ * Rendu normal (sans transition)
2818
+ */
2819
+ renderFull() {
2820
+ this.ctx.save();
2821
+
2822
+ // Séparer les composants fixes et scrollables
2823
+ const scrollableComponents = [];
2824
+ const fixedComponents = [];
2825
+
2826
+ for (let comp of this.components) {
2827
+ if (this.isFixedComponent(comp)) {
2828
+ fixedComponents.push(comp);
2829
+ } else {
2830
+ scrollableComponents.push(comp);
2831
+ }
2832
+ }
2833
+
2834
+ // Dessiner les composants scrollables
2835
+ if (scrollableComponents.length > 0) {
2836
+ this.ctx.save();
2837
+ this.ctx.translate(0, this.scrollOffset);
2838
+
2839
+ for (let comp of scrollableComponents) {
2840
+ if (comp.visible) {
2841
+ comp.draw(this.ctx);
2842
+ }
2843
+ }
2844
+
2845
+ this.ctx.restore();
2846
+ }
2847
+
2848
+ // Dessiner les composants fixes
2849
+ for (let comp of fixedComponents) {
2850
+ if (comp.visible) {
2851
+ comp.draw(this.ctx);
2852
+ }
2853
+ }
2854
+
2855
+ this.ctx.restore();
2856
+ }
2857
+
2858
+ /**
2859
+ * Mettre à jour la progression de la transition
2860
+ * @private
2861
+ */
2862
+ updateTransition() {
2863
+ if (!this.transitionState.isTransitioning) return;
2864
+
2865
+ const elapsed = Date.now() - this.transitionState.startTime;
2866
+ this.transitionState.progress = Math.min(elapsed / this.transitionState.duration, 1);
2867
+
2868
+ // Si la transition est terminée
2869
+ if (this.transitionState.progress >= 1) {
2870
+ this.transitionState.isTransitioning = false;
2871
+
2872
+ // Marquer tous les nouveaux composants comme sales pour le prochain rendu
2873
+ this.transitionState.newComponents.forEach(comp => {
2874
+ this.markComponentDirty(comp);
2875
+ });
2876
+ }
2877
+ }
2878
+
2879
+ /**
2880
+ * Rendu complet normal (sans transition)
2881
+ * @private
2882
+ */
2883
+ renderFull() {
2884
+ // Sauvegarder le contexte
2885
+ this.ctx.save();
2886
+
2887
+ // Séparer les composants
2888
+ const scrollableComponents = [];
2889
+ const fixedComponents = [];
2890
+
2891
+ for (let comp of this.components) {
2892
+ if (this.isFixedComponent(comp)) {
2893
+ fixedComponents.push(comp);
2894
+ } else {
2895
+ scrollableComponents.push(comp);
2896
+ }
2897
+ }
2898
+
2899
+ // Dessiner les composants scrollables avec translation
2900
+ if (scrollableComponents.length > 0) {
2901
+ this.ctx.save();
2902
+ this.ctx.translate(0, this.scrollOffset);
2903
+
2904
+ for (let comp of scrollableComponents) {
2905
+ if (comp.visible) {
2906
+ // Viewport culling
2907
+ const screenY = comp.y + this.scrollOffset;
2908
+ const isInViewport = screenY + comp.height >= -100 && screenY <= this.height + 100;
2909
+
2910
+ if (isInViewport) {
2911
+ comp.draw(this.ctx);
2912
+ }
2913
+ }
2914
+ }
2915
+
2916
+ this.ctx.restore();
2917
+ }
2918
+
2919
+ // Dessiner les composants fixes
2920
+ for (let comp of fixedComponents) {
2921
+ if (comp.visible) {
2922
+ comp.draw(this.ctx);
2923
+ }
2924
+ }
2925
+
2926
+ // Restaurer le contexte
2927
+ this.ctx.restore();
2928
+ }
2929
+
2930
+
2931
+
2932
+ /**
2933
+ * Fait défiler à une position spécifique
2934
+ * @param {number} offset - Position cible
2935
+ * @param {boolean} animated - Avec animation
2936
+ */
2937
+ scrollTo(offset, animated = true) {
2938
+ if (animated) {
2939
+ const startOffset = this.scrollOffset;
2940
+ const delta = offset - startOffset;
2941
+ const duration = 300;
2942
+ const startTime = performance.now();
2943
+
2944
+ const animate = (currentTime) => {
2945
+ const elapsed = currentTime - startTime;
2946
+ const progress = Math.min(elapsed / duration, 1);
2947
+ const ease = this.easeOutCubic(progress);
2948
+ const currentOffset = startOffset + delta * ease;
2949
+
2950
+ this.scrollWorker.postMessage({
2951
+ type: 'SET_SCROLL_OFFSET',
2952
+ payload: {
2953
+ scrollOffset: currentOffset
2954
+ }
2955
+ });
2956
+
2957
+ if (progress < 1) {
2958
+ requestAnimationFrame(animate);
2959
+ }
2960
+ };
2961
+
2962
+ requestAnimationFrame(animate);
2963
+ } else {
2964
+ this.scrollWorker.postMessage({
2965
+ type: 'SET_SCROLL_OFFSET',
2966
+ payload: {
2967
+ scrollOffset: offset
2968
+ }
2969
+ });
2970
+ }
2971
+ }
2972
+
2973
+ /**
2974
+ * Fonction d'easing
2975
+ */
2976
+ easeOutCubic(t) {
2977
+ return 1 - Math.pow(1 - t, 3);
2978
+ }
2979
+
2980
+ /**
2981
+ * Nettoie les ressources
2982
+ */
2983
+ destroy() {
2984
+ if (this.scrollWorker) {
2985
+ this.scrollWorker.terminate();
2986
+ }
2987
+ if (this.worker) {
2988
+ this.worker.terminate();
2989
+ }
2990
+ if (this.logicWorker) {
2991
+ this.logicWorker.terminate();
2992
+ }
2993
+
2994
+ if (this.ctx && typeof this.ctx.destroy === 'function') {
2995
+ this.ctx.destroy();
2996
+ }
2997
+
2998
+ // Nettoyer les écouteurs d'événements
2999
+ this.canvas.removeEventListener('touchstart', this.handleTouchStart);
3000
+ this.canvas.removeEventListener('touchmove', this.handleTouchMove);
3001
+ this.canvas.removeEventListener('touchend', this.handleTouchEnd);
3002
+ this.canvas.removeEventListener('mousedown', this.handleMouseDown);
3003
+ this.canvas.removeEventListener('mousemove', this.handleMouseMove);
3004
+ this.canvas.removeEventListener('mouseup', this.handleMouseUp);
3005
+ window.removeEventListener('resize', this.handleResize);
3006
+ }
3007
+
3008
+ // ✅ AJOUTER: Afficher les métriques à l'écran
3009
+ displayMetrics() {
3010
+ const metrics = this.metrics;
3011
+
3012
+ console.table({
3013
+ '⚙️ Initialisation Framework': `${metrics.initTime.toFixed(2)}ms`,
3014
+ '🎨 Premier Rendu': `${metrics.firstRenderTime.toFixed(2)}ms`,
3015
+ '🚀 Temps Total Startup': `${metrics.totalStartupTime.toFixed(2)}ms`,
3016
+ '📊 FPS Actuel': this.fps
3017
+ });
3018
+ }
3019
+
3020
+ // ✅ AJOUTER: Obtenir les métriques
3021
+ getMetrics() {
3022
+ return {
3023
+ ...this.metrics,
3024
+ currentFPS: this.fps,
3025
+ componentsCount: this.components.length
3026
+ };
3027
+ }
3028
+
3029
+ isFixedComponent(comp) {
3030
+ return FIXED_COMPONENT_TYPES.has(comp.constructor);
3031
+ }
3032
+
3033
+ showToast(message, duration = 3000) {
3034
+ const toast = new Toast(this, {
3035
+ text: message,
3036
+ duration: duration,
3037
+ x: this.width / 2,
3038
+ y: this.height - 100
3039
+ });
3040
+ this.add(toast);
3041
+ toast.show();
3042
+ }
3043
+ }
3044
+
3045
+ export default CanvasFramework;