@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;
|
|
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"}
|
package/dist/barcode-scanner.js
CHANGED
|
@@ -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] }),
|
|
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,10 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { describe, it, expect, afterEach } from 'vitest'
|
|
3
|
-
import {
|
|
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
|
})
|
package/src/barcode-scanner.tsx
CHANGED
|
@@ -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
|
-
<
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
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
|
)}
|