@asteby/metacore-runtime-react 29.2.5 → 29.2.6

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 29.2.6
4
+
5
+ ### Patch Changes
6
+
7
+ - b1a9703: feat(barcode-scanner): botón de linterna (torch) cuando el dispositivo lo soporta
8
+
9
+ En ambientes oscuros se puede encender/apagar el flash de la cámara trasera
10
+ desde la barra del overlay. Si el hardware no expone `torch`, el botón no
11
+ aparece. Aplica a POS y formularios (mismo primitivo).
12
+
3
13
  ## 29.2.5
4
14
 
5
15
  ### Patch Changes
@@ -1,4 +1,6 @@
1
1
  import * as React from 'react';
2
+ /** `true` si el track de video expone linterna (ImageCapture / torch constraint). */
3
+ export declare function trackSupportsTorch(track: MediaStreamTrack | null | undefined): boolean;
2
4
  /** Simbolismos de retail que intentamos detectar. */
3
5
  export declare const RETAIL_BARCODE_FORMATS: string[];
4
6
  /** `true` si este navegador puede escanear con la cámara (detector + getUserMedia). */
@@ -1 +1 @@
1
- {"version":3,"file":"barcode-scanner.d.ts","sourceRoot":"","sources":["../src/barcode-scanner.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAqC9B,qDAAqD;AACrD,eAAO,MAAM,sBAAsB,UASlC,CAAA;AAQD,uFAAuF;AACvF,wBAAgB,qBAAqB,IAAI,OAAO,CAM/C;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,eAyB1B;AAED,MAAM,WAAW,mBAAmB;IAChC,IAAI,EAAE,OAAO,CAAA;IACb,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,8EAA8E;IAC9E,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,uDAAuD;IACvD,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,wCAAwC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,QAAQ,CAAC,EAAE,UAAU,GAAG,OAAO,CAAA;CAClC;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,EAC3B,IAAI,EACJ,OAAO,EACP,UAAU,EACV,UAAiB,EACjB,UAAiB,EACjB,KAAyB,EACzB,IAAmC,EACnC,QAAqB,GACxB,EAAE,mBAAmB,4BA+IrB;AAED,eAAe,cAAc,CAAA"}
1
+ {"version":3,"file":"barcode-scanner.d.ts","sourceRoot":"","sources":["../src/barcode-scanner.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAQ9B,qFAAqF;AACrF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,gBAAgB,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAOtF;AAoDD,qDAAqD;AACrD,eAAO,MAAM,sBAAsB,UASlC,CAAA;AAQD,uFAAuF;AACvF,wBAAgB,qBAAqB,IAAI,OAAO,CAM/C;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,eAyB1B;AAED,MAAM,WAAW,mBAAmB;IAChC,IAAI,EAAE,OAAO,CAAA;IACb,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,8EAA8E;IAC9E,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,uDAAuD;IACvD,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,wCAAwC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,QAAQ,CAAC,EAAE,UAAU,GAAG,OAAO,CAAA;CAClC;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,EAC3B,IAAI,EACJ,OAAO,EACP,UAAU,EACV,UAAiB,EACjB,UAAiB,EACjB,KAAyB,EACzB,IAAmC,EACnC,QAAqB,GACxB,EAAE,mBAAmB,4BAgMrB;AAED,eAAe,cAAc,CAAA"}
@@ -1,7 +1,36 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import * as React from 'react';
3
- import { Camera, ScanLine, X } from 'lucide-react';
3
+ import { Camera, Flashlight, FlashlightOff, ScanLine, X } from 'lucide-react';
4
4
  import { Button } from '@asteby/metacore-ui';
5
+ /** `true` si el track de video expone linterna (ImageCapture / torch constraint). */
6
+ export function trackSupportsTorch(track) {
7
+ if (!track || typeof track.getCapabilities !== 'function')
8
+ return false;
9
+ try {
10
+ return !!track.getCapabilities().torch;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ async function setTrackTorch(track, on) {
17
+ try {
18
+ await track.applyConstraints({
19
+ advanced: [{ torch: on }],
20
+ });
21
+ return true;
22
+ }
23
+ catch {
24
+ // Algunos WebViews fallan con advanced[] — reintento plano.
25
+ try {
26
+ await track.applyConstraints({ torch: on });
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ }
5
34
  /** Simbolismos de retail que intentamos detectar. */
6
35
  export const RETAIL_BARCODE_FORMATS = [
7
36
  'ean_13',
@@ -71,19 +100,38 @@ export function BarcodeScanner({ open, onClose, onDetected, continuous = true, b
71
100
  const lastHitRef = React.useRef({ code: '', at: 0 });
72
101
  const [error, setError] = React.useState(null);
73
102
  const [ready, setReady] = React.useState(false);
103
+ const [torchAvailable, setTorchAvailable] = React.useState(false);
104
+ const [torchOn, setTorchOn] = React.useState(false);
74
105
  const beep = useScanBeep();
75
106
  const stop = React.useCallback(() => {
76
107
  if (rafRef.current != null)
77
108
  cancelAnimationFrame(rafRef.current);
78
109
  rafRef.current = null;
110
+ const track = streamRef.current?.getVideoTracks()[0];
111
+ if (track && trackSupportsTorch(track)) {
112
+ void setTrackTorch(track, false);
113
+ }
79
114
  streamRef.current?.getTracks().forEach((t) => t.stop());
80
115
  streamRef.current = null;
81
116
  setReady(false);
117
+ setTorchAvailable(false);
118
+ setTorchOn(false);
82
119
  }, []);
120
+ const toggleTorch = React.useCallback(async () => {
121
+ const track = streamRef.current?.getVideoTracks()[0];
122
+ if (!track || !trackSupportsTorch(track))
123
+ return;
124
+ const next = !torchOn;
125
+ const ok = await setTrackTorch(track, next);
126
+ if (ok)
127
+ setTorchOn(next);
128
+ }, [torchOn]);
83
129
  React.useEffect(() => {
84
130
  if (!open)
85
131
  return;
86
132
  setError(null);
133
+ setTorchAvailable(false);
134
+ setTorchOn(false);
87
135
  const Ctor = getDetectorCtor();
88
136
  if (!navigator.mediaDevices?.getUserMedia || !Ctor) {
89
137
  setError('Tu navegador no soporta escaneo por cámara. Usá un lector físico (escribe en el campo) o escribí el código a mano.');
@@ -132,6 +180,8 @@ export function BarcodeScanner({ open, onClose, onDetected, continuous = true, b
132
180
  return;
133
181
  }
134
182
  streamRef.current = stream;
183
+ const videoTrack = stream.getVideoTracks()[0];
184
+ setTorchAvailable(trackSupportsTorch(videoTrack));
135
185
  const video = videoRef.current;
136
186
  if (video) {
137
187
  video.srcObject = stream;
@@ -155,6 +205,12 @@ export function BarcodeScanner({ open, onClose, onDetected, continuous = true, b
155
205
  if (!open)
156
206
  return null;
157
207
  const posClass = position === 'fixed' ? 'fixed' : 'absolute';
158
- return (_jsxs("div", { className: `${posClass} inset-0 z-[100] flex flex-col bg-black`, children: [_jsxs("div", { className: "flex items-center justify-between px-4 py-3 text-white", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx(ScanLine, { className: "size-5" }), title] }), _jsx(Button, { variant: "ghost", size: "icon", className: "text-white hover:bg-white/10 hover:text-white", onClick: onClose, "aria-label": "Cerrar esc\u00E1ner", children: _jsx(X, { className: "size-5" }) })] }), _jsx("div", { className: "relative flex flex-1 items-center justify-center overflow-hidden", children: error ? (_jsxs("div", { className: "flex max-w-sm flex-col items-center gap-3 px-6 text-center text-white", children: [_jsx(Camera, { className: "size-10 opacity-70" }), _jsx("p", { className: "text-sm leading-relaxed", children: error }), _jsx(Button, { variant: "secondary", onClick: onClose, children: "Entendido" })] })) : (_jsxs(_Fragment, { children: [_jsx("video", { ref: videoRef, className: "h-full w-full object-cover", muted: true, playsInline: true }), _jsx("div", { className: "pointer-events-none absolute inset-0 flex items-center justify-center", children: _jsx("div", { className: "relative h-40 w-72 max-w-[80%] rounded-xl border-2 border-white/80 shadow-[0_0_0_9999px_rgba(0,0,0,0.35)]", children: _jsx("span", { className: "bg-primary absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 animate-pulse" }) }) }), _jsx("p", { className: "absolute bottom-8 left-0 right-0 text-center text-sm text-white/90", children: ready ? hint : 'Abriendo cámara…' })] })) })] }));
208
+ return (_jsxs("div", { className: `${posClass} inset-0 z-[100] flex flex-col bg-black`, children: [_jsxs("div", { className: "flex items-center justify-between px-4 py-3 text-white", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx(ScanLine, { className: "size-5" }), title] }), _jsxs("div", { className: "flex items-center gap-1", children: [torchAvailable && (_jsx(Button, { type: "button", variant: "ghost", size: "icon", className: torchOn
209
+ ? 'bg-amber-400/20 text-amber-300 hover:bg-amber-400/30 hover:text-amber-200'
210
+ : 'text-white hover:bg-white/10 hover:text-white', onClick: () => void toggleTorch(), "aria-label": torchOn ? 'Apagar linterna' : 'Encender linterna', "aria-pressed": torchOn, title: torchOn ? 'Apagar linterna' : 'Encender linterna', children: torchOn ? (_jsx(Flashlight, { className: "size-5" })) : (_jsx(FlashlightOff, { className: "size-5" })) })), _jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "text-white hover:bg-white/10 hover:text-white", onClick: onClose, "aria-label": "Cerrar esc\u00E1ner", children: _jsx(X, { className: "size-5" }) })] })] }), _jsx("div", { className: "relative flex flex-1 items-center justify-center overflow-hidden", children: error ? (_jsxs("div", { className: "flex max-w-sm flex-col items-center gap-3 px-6 text-center text-white", children: [_jsx(Camera, { className: "size-10 opacity-70" }), _jsx("p", { className: "text-sm leading-relaxed", children: error }), _jsx(Button, { variant: "secondary", onClick: onClose, children: "Entendido" })] })) : (_jsxs(_Fragment, { children: [_jsx("video", { ref: videoRef, className: "h-full w-full object-cover", muted: true, playsInline: true }), _jsx("div", { className: "pointer-events-none absolute inset-0 flex items-center justify-center", children: _jsx("div", { className: "relative h-40 w-72 max-w-[80%] rounded-xl border-2 border-white/80 shadow-[0_0_0_9999px_rgba(0,0,0,0.35)]", children: _jsx("span", { className: "bg-primary absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 animate-pulse" }) }) }), _jsx("p", { className: "absolute bottom-8 left-0 right-0 px-4 text-center text-sm text-white/90", children: !ready
211
+ ? 'Abriendo cámara…'
212
+ : torchAvailable
213
+ ? `${hint} · tocá la linterna si está oscuro`
214
+ : hint })] })) })] }));
159
215
  }
160
216
  export default BarcodeScanner;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "29.2.5",
3
+ "version": "29.2.6",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,6 +1,10 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { describe, it, expect, afterEach } from 'vitest'
3
- import { isCameraScanSupported, RETAIL_BARCODE_FORMATS } from '../barcode-scanner'
3
+ import {
4
+ isCameraScanSupported,
5
+ RETAIL_BARCODE_FORMATS,
6
+ trackSupportsTorch,
7
+ } from '../barcode-scanner'
4
8
 
5
9
  describe('barcode-scanner primitive', () => {
6
10
  afterEach(() => {
@@ -25,4 +29,18 @@ describe('barcode-scanner primitive', () => {
25
29
  const hasGUM = !!navigator.mediaDevices?.getUserMedia
26
30
  expect(isCameraScanSupported()).toBe(hasGUM)
27
31
  })
32
+
33
+ it('trackSupportsTorch lee capabilities.torch del track', () => {
34
+ expect(trackSupportsTorch(null)).toBe(false)
35
+ expect(
36
+ trackSupportsTorch({
37
+ getCapabilities: () => ({}),
38
+ } as MediaStreamTrack),
39
+ ).toBe(false)
40
+ expect(
41
+ trackSupportsTorch({
42
+ getCapabilities: () => ({ torch: true }),
43
+ } as unknown as MediaStreamTrack),
44
+ ).toBe(true)
45
+ })
28
46
  })
@@ -1,7 +1,38 @@
1
1
  import * as React from 'react'
2
- import { Camera, ScanLine, X } from 'lucide-react'
2
+ import { Camera, Flashlight, FlashlightOff, ScanLine, X } from 'lucide-react'
3
3
  import { Button } from '@asteby/metacore-ui'
4
4
 
5
+ /** `torch` aún no está en todas las libs de TS — extendemos el contrato mínimo. */
6
+ type TorchCapabilities = MediaTrackCapabilities & { torch?: boolean }
7
+ type TorchConstraintSet = MediaTrackConstraintSet & { torch?: boolean }
8
+
9
+ /** `true` si el track de video expone linterna (ImageCapture / torch constraint). */
10
+ export function trackSupportsTorch(track: MediaStreamTrack | null | undefined): boolean {
11
+ if (!track || typeof track.getCapabilities !== 'function') return false
12
+ try {
13
+ return !!(track.getCapabilities() as TorchCapabilities).torch
14
+ } catch {
15
+ return false
16
+ }
17
+ }
18
+
19
+ async function setTrackTorch(track: MediaStreamTrack, on: boolean): Promise<boolean> {
20
+ try {
21
+ await track.applyConstraints({
22
+ advanced: [{ torch: on } as TorchConstraintSet],
23
+ })
24
+ return true
25
+ } catch {
26
+ // Algunos WebViews fallan con advanced[] — reintento plano.
27
+ try {
28
+ await track.applyConstraints({ torch: on } as MediaTrackConstraints)
29
+ return true
30
+ } catch {
31
+ return false
32
+ }
33
+ }
34
+ }
35
+
5
36
  /**
6
37
  * BarcodeScanner — primitivo REUSABLE de escaneo por cámara para todo el
7
38
  * ecosistema (POS, formularios declarativos, cualquier addon). Sin dependencias
@@ -141,19 +172,37 @@ export function BarcodeScanner({
141
172
  const lastHitRef = React.useRef<{ code: string; at: number }>({ code: '', at: 0 })
142
173
  const [error, setError] = React.useState<string | null>(null)
143
174
  const [ready, setReady] = React.useState(false)
175
+ const [torchAvailable, setTorchAvailable] = React.useState(false)
176
+ const [torchOn, setTorchOn] = React.useState(false)
144
177
  const beep = useScanBeep()
145
178
 
146
179
  const stop = React.useCallback(() => {
147
180
  if (rafRef.current != null) cancelAnimationFrame(rafRef.current)
148
181
  rafRef.current = null
182
+ const track = streamRef.current?.getVideoTracks()[0]
183
+ if (track && trackSupportsTorch(track)) {
184
+ void setTrackTorch(track, false)
185
+ }
149
186
  streamRef.current?.getTracks().forEach((t) => t.stop())
150
187
  streamRef.current = null
151
188
  setReady(false)
189
+ setTorchAvailable(false)
190
+ setTorchOn(false)
152
191
  }, [])
153
192
 
193
+ const toggleTorch = React.useCallback(async () => {
194
+ const track = streamRef.current?.getVideoTracks()[0]
195
+ if (!track || !trackSupportsTorch(track)) return
196
+ const next = !torchOn
197
+ const ok = await setTrackTorch(track, next)
198
+ if (ok) setTorchOn(next)
199
+ }, [torchOn])
200
+
154
201
  React.useEffect(() => {
155
202
  if (!open) return
156
203
  setError(null)
204
+ setTorchAvailable(false)
205
+ setTorchOn(false)
157
206
 
158
207
  const Ctor = getDetectorCtor()
159
208
  if (!navigator.mediaDevices?.getUserMedia || !Ctor) {
@@ -205,6 +254,8 @@ export function BarcodeScanner({
205
254
  return
206
255
  }
207
256
  streamRef.current = stream
257
+ const videoTrack = stream.getVideoTracks()[0]
258
+ setTorchAvailable(trackSupportsTorch(videoTrack))
208
259
  const video = videoRef.current
209
260
  if (video) {
210
261
  video.srcObject = stream
@@ -239,15 +290,40 @@ export function BarcodeScanner({
239
290
  <ScanLine className="size-5" />
240
291
  {title}
241
292
  </div>
242
- <Button
243
- variant="ghost"
244
- size="icon"
245
- className="text-white hover:bg-white/10 hover:text-white"
246
- onClick={onClose}
247
- aria-label="Cerrar escáner"
248
- >
249
- <X className="size-5" />
250
- </Button>
293
+ <div className="flex items-center gap-1">
294
+ {torchAvailable && (
295
+ <Button
296
+ type="button"
297
+ variant="ghost"
298
+ size="icon"
299
+ className={
300
+ torchOn
301
+ ? 'bg-amber-400/20 text-amber-300 hover:bg-amber-400/30 hover:text-amber-200'
302
+ : 'text-white hover:bg-white/10 hover:text-white'
303
+ }
304
+ onClick={() => void toggleTorch()}
305
+ aria-label={torchOn ? 'Apagar linterna' : 'Encender linterna'}
306
+ aria-pressed={torchOn}
307
+ title={torchOn ? 'Apagar linterna' : 'Encender linterna'}
308
+ >
309
+ {torchOn ? (
310
+ <Flashlight className="size-5" />
311
+ ) : (
312
+ <FlashlightOff className="size-5" />
313
+ )}
314
+ </Button>
315
+ )}
316
+ <Button
317
+ type="button"
318
+ variant="ghost"
319
+ size="icon"
320
+ className="text-white hover:bg-white/10 hover:text-white"
321
+ onClick={onClose}
322
+ aria-label="Cerrar escáner"
323
+ >
324
+ <X className="size-5" />
325
+ </Button>
326
+ </div>
251
327
  </div>
252
328
 
253
329
  {/* Área de cámara */}
@@ -269,8 +345,12 @@ export function BarcodeScanner({
269
345
  <span className="bg-primary absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 animate-pulse" />
270
346
  </div>
271
347
  </div>
272
- <p className="absolute bottom-8 left-0 right-0 text-center text-sm text-white/90">
273
- {ready ? hint : 'Abriendo cámara…'}
348
+ <p className="absolute bottom-8 left-0 right-0 px-4 text-center text-sm text-white/90">
349
+ {!ready
350
+ ? 'Abriendo cámara…'
351
+ : torchAvailable
352
+ ? `${hint} · tocá la linterna si está oscuro`
353
+ : hint}
274
354
  </p>
275
355
  </>
276
356
  )}