canvasframework 0.3.24 → 0.3.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,392 @@
1
+ import Component from '../core/Component.js';
2
+
3
+ /**
4
+ * Composant Camera autonome avec gestion directe des clics/touches
5
+ * Modes : contain (tout visible + bandes), cover (remplit + crop), fit (centre sans crop)
6
+ */
7
+ class Camera extends Component {
8
+ constructor(framework, options = {}) {
9
+ super(framework, options);
10
+
11
+ this.facingMode = options.facingMode || 'environment';
12
+ this.autoStart = options.autoStart !== false;
13
+ this.onPhoto = options.onPhoto || null;
14
+
15
+ this.fitModes = ['contain', 'cover'];
16
+ this.currentFitModeIndex = 0;
17
+ this.fitMode = this.fitModes[this.currentFitModeIndex];
18
+
19
+ this.stream = null;
20
+ this.video = null;
21
+ this.loaded = false;
22
+ this.error = null;
23
+
24
+ this.showControls = true;
25
+ this.torchSupported = false;
26
+ this.torchOn = false;
27
+ this.captureButtonRadius = 35;
28
+ this.modeButtonSize = 40;
29
+
30
+ // Feedback capture
31
+ this.flashTimer = null;
32
+ this.previewPhoto = null; // dataUrl dernière photo
33
+ this.previewTimeout = null;
34
+
35
+ this.isStarting = false;
36
+ }
37
+
38
+ async _mount() {
39
+ super._mount?.();
40
+
41
+ // Redémarrage auto si besoin
42
+ if (this.autoStart && !this.stream && !this.isStarting) {
43
+ this.isStarting = true;
44
+ await this.startCamera();
45
+ this.isStarting = false;
46
+ }
47
+
48
+ this.setupEventListeners();
49
+ }
50
+
51
+ destroy() {
52
+ this.removeEventListeners();
53
+ this.stopCamera();
54
+ if (this.flashTimer) clearTimeout(this.flashTimer);
55
+ if (this.previewTimeout) clearTimeout(this.previewTimeout);
56
+ super.destroy?.();
57
+ }
58
+
59
+ // Écoute directe (indépendante du framework)
60
+ setupEventListeners() {
61
+ this.onTouchStart = this.handleTouchStart.bind(this);
62
+ this.onTouchMove = this.handleTouchMove.bind(this);
63
+ this.onTouchEnd = this.handleTouchEnd.bind(this);
64
+ this.onMouseDown = this.handleMouseDown.bind(this);
65
+ this.onMouseMove = this.handleMouseMove.bind(this);
66
+ this.onMouseUp = this.handleMouseUp.bind(this);
67
+
68
+ const canvas = this.framework.canvas;
69
+ canvas.addEventListener('touchstart', this.onTouchStart, { passive: false });
70
+ canvas.addEventListener('touchmove', this.onTouchMove, { passive: false });
71
+ canvas.addEventListener('touchend', this.onTouchEnd, { passive: false });
72
+ canvas.addEventListener('mousedown', this.onMouseDown);
73
+ canvas.addEventListener('mousemove', this.onMouseMove);
74
+ canvas.addEventListener('mouseup', this.onMouseUp);
75
+ }
76
+
77
+ removeEventListeners() {
78
+ const canvas = this.framework.canvas;
79
+ canvas.removeEventListener('touchstart', this.onTouchStart);
80
+ canvas.removeEventListener('touchmove', this.onTouchMove);
81
+ canvas.removeEventListener('touchend', this.onTouchEnd);
82
+ canvas.removeEventListener('mousedown', this.onMouseDown);
83
+ canvas.removeEventListener('mousemove', this.onMouseMove);
84
+ canvas.removeEventListener('mouseup', this.onMouseUp);
85
+ }
86
+
87
+ // Coordonnées locales
88
+ getLocalPos(clientX, clientY) {
89
+ const rect = this.framework.canvas.getBoundingClientRect();
90
+ const globalX = clientX - rect.left;
91
+ const globalY = clientY - rect.top - this.framework.scrollOffset;
92
+ return {
93
+ x: globalX - this.x,
94
+ y: globalY - this.y
95
+ };
96
+ }
97
+
98
+ handleTouchStart(e) {
99
+ e.preventDefault();
100
+ const touch = e.touches[0];
101
+ const pos = this.getLocalPos(touch.clientX, touch.clientY);
102
+ this.handlePress(pos.x, pos.y);
103
+ }
104
+
105
+ handleTouchMove(e) {
106
+ e.preventDefault();
107
+ }
108
+
109
+ handleTouchEnd(e) {
110
+ e.preventDefault();
111
+ }
112
+
113
+ handleMouseDown(e) {
114
+ const pos = this.getLocalPos(e.clientX, e.clientY);
115
+ this.handlePress(pos.x, pos.y);
116
+ }
117
+
118
+ handleMouseMove(e) {}
119
+
120
+ handleMouseUp(e) {}
121
+
122
+ async startCamera() {
123
+ if (this.stream) return;
124
+
125
+ try {
126
+ this.stopCamera();
127
+
128
+ this.stream = await navigator.mediaDevices.getUserMedia({
129
+ video: {
130
+ facingMode: this.facingMode,
131
+ width: { ideal: 1280 },
132
+ height: { ideal: 720 },
133
+ frameRate: { ideal: 30 }
134
+ }
135
+ });
136
+
137
+ const [track] = this.stream.getVideoTracks();
138
+ const caps = track.getCapabilities?.() || {};
139
+ this.torchSupported = !!caps.torch;
140
+
141
+ this.video = document.createElement('video');
142
+ this.video.autoplay = true;
143
+ this.video.playsInline = true;
144
+ this.video.muted = true;
145
+ this.video.srcObject = this.stream;
146
+
147
+ this.video.style.position = 'fixed';
148
+ this.video.style.left = '-9999px';
149
+ this.video.style.top = '-9999px';
150
+ this.video.style.width = '1px';
151
+ this.video.style.height = '1px';
152
+ document.body.appendChild(this.video);
153
+
154
+ await new Promise(resolve => {
155
+ this.video.onloadedmetadata = () => {
156
+ this.loaded = true;
157
+ this.video.play().catch(e => console.warn('Play auto échoué:', e));
158
+ resolve();
159
+ };
160
+ this.video.onerror = (e) => {
161
+ this.error = 'Erreur vidéo';
162
+ console.error('Video error:', e);
163
+ resolve();
164
+ };
165
+ });
166
+
167
+ this.markDirty();
168
+ } catch (err) {
169
+ this.error = err.message || 'Accès caméra refusé';
170
+ console.error('Échec getUserMedia:', err);
171
+ this.markDirty();
172
+ }
173
+ }
174
+
175
+ stopCamera() {
176
+ if (this.stream) {
177
+ this.stream.getTracks().forEach(track => track.stop());
178
+ this.stream = null;
179
+ }
180
+ if (this.video) {
181
+ if (this.video.parentNode) {
182
+ this.video.parentNode.removeChild(this.video);
183
+ }
184
+ this.video.srcObject = null;
185
+ this.video = null;
186
+ }
187
+ this.loaded = false;
188
+ this.markDirty();
189
+ }
190
+
191
+ async switchCamera() {
192
+ this.stopCamera();
193
+ this.facingMode = this.facingMode === 'user' ? 'environment' : 'user';
194
+ await this.startCamera();
195
+ }
196
+
197
+ async toggleTorch() {
198
+ if (!this.torchSupported || !this.stream) return;
199
+
200
+ const [track] = this.stream.getVideoTracks();
201
+ try {
202
+ await track.applyConstraints({
203
+ advanced: [{ torch: !this.torchOn }]
204
+ });
205
+ this.torchOn = !this.torchOn;
206
+ this.markDirty();
207
+ } catch (err) {
208
+ console.warn('Torch impossible:', err);
209
+ }
210
+ }
211
+
212
+ capturePhoto() {
213
+ if (!this.loaded || !this.video) {
214
+ console.warn('Capture impossible : caméra pas prête');
215
+ return null;
216
+ }
217
+
218
+ const canvas = document.createElement('canvas');
219
+ canvas.width = this.video.videoWidth;
220
+ canvas.height = this.video.videoHeight;
221
+
222
+ const ctx = canvas.getContext('2d');
223
+ ctx.drawImage(this.video, 0, 0);
224
+
225
+ const dataUrl = canvas.toDataURL('image/jpeg', 0.92);
226
+ if (this.onPhoto) this.onPhoto(dataUrl);
227
+ console.log('Photo capturée ! DataURL:', dataUrl.substring(0, 50) + '...');
228
+
229
+ // Feedback : flash + preview 3s
230
+ this.flashTimer = setTimeout(() => {
231
+ this.flashTimer = null;
232
+ this.markDirty();
233
+ }, 200);
234
+
235
+ this.previewPhoto = dataUrl;
236
+ this.previewTimeout = setTimeout(() => {
237
+ this.previewPhoto = null;
238
+ this.markDirty();
239
+ }, 3000);
240
+
241
+ this.markDirty();
242
+ return dataUrl;
243
+ }
244
+
245
+ switchFitMode() {
246
+ this.currentFitModeIndex = (this.currentFitModeIndex + 1) % this.fitModes.length;
247
+ this.fitMode = this.fitModes[this.currentFitModeIndex];
248
+ this.markDirty();
249
+ }
250
+
251
+ handlePress(relX, relY) {
252
+
253
+ // Capture centrale
254
+ const captureX = this.width / 2;
255
+ const captureY = this.height - 60;
256
+ if (Math.hypot(relX - captureX, relY - captureY) < this.captureButtonRadius + 10) {
257
+ this.capturePhoto();
258
+ return;
259
+ }
260
+
261
+ // Switch caméra
262
+ if (relX < 60 && relY < 60) {
263
+ this.switchCamera();
264
+ return;
265
+ }
266
+
267
+ // Torch
268
+ if (this.torchSupported && relX > this.width - 60 && relY < 60) {
269
+ this.toggleTorch();
270
+ return;
271
+ }
272
+
273
+ // Switch mode
274
+ const modeButtonX = this.width - 80;
275
+ const modeButtonY = 20;
276
+ if (relX > modeButtonX && relX < modeButtonX + this.modeButtonSize &&
277
+ relY > modeButtonY && relY < modeButtonY + this.modeButtonSize) {
278
+ this.switchFitMode();
279
+ return;
280
+ }
281
+ }
282
+
283
+ draw(ctx) {
284
+ ctx.save();
285
+
286
+ ctx.fillStyle = '#000';
287
+ ctx.fillRect(this.x, this.y, this.width, this.height);
288
+
289
+ // Flash blanc après capture
290
+ if (this.flashTimer) {
291
+ ctx.fillStyle = 'rgba(255,255,255,0.6)';
292
+ ctx.fillRect(this.x, this.y, this.width, this.height);
293
+ }
294
+
295
+ if (this.error) {
296
+ ctx.fillStyle = '#ff4444';
297
+ ctx.font = '16px Arial';
298
+ ctx.textAlign = 'center';
299
+ ctx.textBaseline = 'middle';
300
+ ctx.fillText(this.error, this.x + this.width/2, this.y + this.height/2);
301
+ } else if (!this.loaded) {
302
+ ctx.fillStyle = '#fff';
303
+ ctx.font = '16px Arial';
304
+ ctx.textAlign = 'center';
305
+ ctx.textBaseline = 'middle';
306
+ ctx.fillText('Démarrage caméra...', this.x + this.width/2, this.y + this.height/2);
307
+ } else if (this.video && this.loaded) {
308
+ const videoRatio = this.video.videoWidth / this.video.videoHeight;
309
+ const canvasRatio = this.width / this.height;
310
+
311
+ let drawWidth = this.width;
312
+ let drawHeight = this.height;
313
+ let offsetX = 0;
314
+ let offsetY = 0;
315
+
316
+ if (this.fitMode === 'cover') {
317
+ if (videoRatio > canvasRatio) {
318
+ drawHeight = this.height;
319
+ drawWidth = drawHeight * videoRatio;
320
+ offsetX = (this.width - drawWidth) / 2;
321
+ } else {
322
+ drawWidth = this.width;
323
+ drawHeight = drawWidth / videoRatio;
324
+ offsetY = (this.height - drawHeight) / 2;
325
+ }
326
+ } else if (this.fitMode === 'contain') {
327
+ if (videoRatio > canvasRatio) {
328
+ drawWidth = this.width;
329
+ drawHeight = drawWidth / videoRatio;
330
+ offsetY = (this.height - drawHeight) / 2;
331
+ } else {
332
+ drawHeight = this.height;
333
+ drawWidth = drawHeight * videoRatio;
334
+ offsetX = (this.width - drawWidth) / 2;
335
+ }
336
+ }
337
+
338
+ ctx.drawImage(this.video, this.x + offsetX, this.y + offsetY, drawWidth, drawHeight);
339
+
340
+ // Mini preview dernière photo (bas droite, 3s)
341
+ if (this.previewPhoto) {
342
+ const previewSize = 80;
343
+ ctx.drawImage(this.previewPhoto, this.x + this.width - previewSize - 10, this.y + this.height - previewSize - 10, previewSize, previewSize);
344
+ ctx.strokeStyle = '#fff';
345
+ ctx.lineWidth = 2;
346
+ ctx.strokeRect(this.x + this.width - previewSize - 10, this.y + this.height - previewSize - 10, previewSize, previewSize);
347
+ }
348
+ }
349
+
350
+ // Contrôles bas
351
+ ctx.fillStyle = 'rgba(0,0,0,0.5)';
352
+ ctx.fillRect(this.x, this.y + this.height - 100, this.width, 100);
353
+
354
+ // Bouton capture
355
+ ctx.fillStyle = '#ffffff';
356
+ ctx.beginPath();
357
+ ctx.arc(this.x + this.width/2, this.y + this.height - 50, this.captureButtonRadius, 0, Math.PI * 2);
358
+ ctx.fill();
359
+
360
+ ctx.strokeStyle = '#ff4444';
361
+ ctx.lineWidth = 6;
362
+ ctx.beginPath();
363
+ ctx.arc(this.x + this.width/2, this.y + this.height - 50, this.captureButtonRadius + 10, 0, Math.PI * 2);
364
+ ctx.stroke();
365
+
366
+ // Switch caméra
367
+ ctx.fillStyle = '#ffffff';
368
+ ctx.font = '28px Arial';
369
+ ctx.fillText('↻', this.x + 20, this.y + 45);
370
+
371
+ // Torch
372
+ if (this.torchSupported) {
373
+ ctx.fillStyle = this.torchOn ? '#ffeb3b' : '#ffffff';
374
+ ctx.fillText('⚡', this.x + this.width - 50, this.y + 45);
375
+ }
376
+
377
+ // Bouton switch mode
378
+ ctx.fillStyle = '#ffffff';
379
+ ctx.fillRect(this.x + this.width - 80, this.y + 20, this.modeButtonSize, this.modeButtonSize);
380
+ ctx.fillStyle = '#000';
381
+ ctx.font = '14px Arial';
382
+ ctx.textAlign = 'center';
383
+ ctx.textBaseline = 'middle';
384
+ ctx.fillText(this.fitMode.toUpperCase().slice(0, 4),
385
+ this.x + this.width - 80 + this.modeButtonSize/2,
386
+ this.y + 20 + this.modeButtonSize/2);
387
+
388
+ ctx.restore();
389
+ }
390
+ }
391
+
392
+ export default Camera;
@@ -54,6 +54,7 @@ import Banner from '../components/Banner.js';
54
54
  import Chart from '../components/Chart.js';
55
55
  import SliverAppBar from '../components/SliverAppBar.js';
56
56
  import AudioPlayer from '../components/AudioPlayer.js';
57
+ import Camera from '../components/Camera.js';
57
58
 
58
59
  // Utils
59
60
  import SafeArea from '../utils/SafeArea.js';
@@ -178,8 +179,22 @@ class CanvasFramework {
178
179
  this.lightTheme = lightTheme;
179
180
  this.darkTheme = darkTheme;
180
181
  this.theme = lightTheme; // thème par défaut
181
-
182
+ // État actuel + préférence
183
+ this.themeMode = options.themeMode || 'system'; // 'light', 'dark', 'system'
184
+ this.userThemeOverride = null; // null = suit system, sinon 'light' ou 'dark'
185
+
186
+ // Applique le thème initial
187
+ this.setupSystemThemeListener();
188
+
189
+ // Récupère override utilisateur
190
+ const savedOverride = localStorage.getItem('themeOverride');
191
+ if (savedOverride && ['light', 'dark'].includes(savedOverride)) {
192
+ this.userThemeOverride = savedOverride;
193
+ this.themeMode = savedOverride;
194
+ }
195
+
182
196
  this.components = [];
197
+ this.applyThemeFromSystem();
183
198
  this.state = {};
184
199
  // NOUVELLE OPTION: choisir entre Canvas 2D et WebGL
185
200
  this.useWebGL = options.useWebGL !== false; // true par défaut
@@ -265,6 +280,70 @@ class CanvasFramework {
265
280
  }
266
281
  }
267
282
 
283
+ /**
284
+ * Détecte le thème système et applique si mode = 'system'
285
+ */
286
+ applyThemeFromSystem() {
287
+ if (this.themeMode === 'system') {
288
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
289
+ const newTheme = prefersDark ? this.darkTheme : this.lightTheme;
290
+ this.setTheme(newTheme);
291
+ } else {
292
+ // Mode forcé
293
+ this.setTheme(
294
+ this.themeMode === 'dark' ? this.darkTheme : this.lightTheme
295
+ );
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Écoute les changements système (ex: utilisateur bascule dark mode)
301
+ */
302
+ setupSystemThemeListener() {
303
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
304
+
305
+ // Ancienne méthode (compatibilité large)
306
+ if (mediaQuery.addEventListener) {
307
+ mediaQuery.addEventListener('change', (e) => {
308
+ if (this.themeMode === 'system') {
309
+ this.applyThemeFromSystem();
310
+ }
311
+ });
312
+ } else {
313
+ // Anciens navigateurs (rare en 2026)
314
+ mediaQuery.addListener((e) => {
315
+ if (this.themeMode === 'system') {
316
+ this.applyThemeFromSystem();
317
+ }
318
+ });
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Change le mode thème
324
+ * @param {'light'|'dark'|'system'} mode - Mode à appliquer
325
+ * @param {boolean} [save=true] - Sauvegarder le choix utilisateur ?
326
+ */
327
+ setThemeMode(mode, save = true) {
328
+ if (!['light', 'dark', 'system'].includes(mode)) {
329
+ console.warn('Mode invalide, valeurs acceptées: light, dark, system');
330
+ return;
331
+ }
332
+
333
+ this.themeMode = mode;
334
+
335
+ if (save && mode !== 'system') {
336
+ this.userThemeOverride = mode;
337
+ // Sauvegarde (ex: localStorage ou ton SecureStorage)
338
+ localStorage.setItem('themeOverride', mode);
339
+ } else if (mode === 'system') {
340
+ this.userThemeOverride = null;
341
+ localStorage.removeItem('themeOverride');
342
+ }
343
+
344
+ this.applyThemeFromSystem();
345
+ }
346
+
268
347
  /**
269
348
  * Active ou désactive les DevTools
270
349
  * @param {boolean} enabled - true pour activer, false pour désactiver
@@ -431,13 +510,16 @@ class CanvasFramework {
431
510
  setTheme(theme) {
432
511
  this.theme = theme;
433
512
 
434
- // Intercepter le context pour remplacer les couleurs globalement
435
513
  if (!this.useWebGL) {
436
514
  this.wrapContext(this.ctx, theme);
437
515
  }
438
516
 
439
- // marque tous les composants dirty pour redraw
440
- for (let comp of this.components) comp.markDirty();
517
+ // Protège la boucle
518
+ if (this.components && Array.isArray(this.components)) {
519
+ this.components.forEach(comp => comp.markDirty());
520
+ } else {
521
+ console.warn('[setTheme] components pas encore initialisé');
522
+ }
441
523
  }
442
524
 
443
525
  // Switch Theme
package/core/UIBuilder.js CHANGED
@@ -55,6 +55,7 @@ import Banner from '../components/Banner.js';
55
55
  import Chart from '../components/Chart.js';
56
56
  import SliverAppBar from '../components/SliverAppBar.js';
57
57
  import AudioPlayer from '../components/AudioPlayer.js';
58
+ import Camera from '../components/Camera.js';
58
59
 
59
60
  // Features
60
61
  import PullToRefresh from '../features/PullToRefresh.js';
@@ -96,6 +97,7 @@ const Components = {
96
97
  AppBar,
97
98
  SliverAppBar,
98
99
  Chip,
100
+ Camera,
99
101
  Stepper,
100
102
  Accordion,
101
103
  Tabs,
package/index.js CHANGED
@@ -62,6 +62,7 @@ export { default as Banner } from './components/Banner.js';
62
62
  export { default as Chart } from './components/Chart.js';
63
63
  export { default as SliverAppBar } from './components/SliverAppBar.js';
64
64
  export { default as AudioPlayer } from './components/AudioPlayer.js';
65
+ export { default as Camera } from './components/Camera.js';
65
66
 
66
67
  // Utils
67
68
  export { default as SafeArea } from './utils/SafeArea.js';
@@ -104,7 +105,7 @@ export { default as FeatureFlags } from './manager/FeatureFlags.js';
104
105
 
105
106
  // Version du framework
106
107
 
107
- export const VERSION = '0.3.24';
108
+ export const VERSION = '0.3.23';
108
109
 
109
110
 
110
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvasframework",
3
- "version": "0.3.24",
3
+ "version": "0.3.26",
4
4
  "description": "Canvas-based cross-platform UI framework (Material & Cupertino)",
5
5
  "type": "module",
6
6
  "main": "./index.js",