@marianmeres/scanner 1.0.1
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/AGENTS.md +144 -0
- package/API.md +696 -0
- package/CLAUDE.md +3 -0
- package/LICENSE +21 -0
- package/README.md +272 -0
- package/dist/camera-adapter.d.ts +31 -0
- package/dist/camera-adapter.js +96 -0
- package/dist/detector.d.ts +25 -0
- package/dist/detector.js +36 -0
- package/dist/mod.d.ts +5 -0
- package/dist/mod.js +5 -0
- package/dist/scan-image.d.ts +24 -0
- package/dist/scan-image.js +35 -0
- package/dist/scanner.d.ts +17 -0
- package/dist/scanner.js +594 -0
- package/dist/stage.d.ts +59 -0
- package/dist/stage.js +334 -0
- package/dist/types.d.ts +262 -0
- package/dist/types.js +28 -0
- package/package.json +40 -0
package/dist/stage.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic DOM "stage" for the scanner — a mountable camera
|
|
3
|
+
* preview with the typical scanning UX: dimmed overlay with a viewfinder
|
|
4
|
+
* cutout, corner guides, optional scan-line animation and opt-in control
|
|
5
|
+
* buttons (cancel / torch / camera switch).
|
|
6
|
+
*
|
|
7
|
+
* The stage is a pure consumer of the headless {@linkcode Scanner} — it
|
|
8
|
+
* renders its video element and reflects its reactive state. Destroying the
|
|
9
|
+
* stage does NOT stop or destroy the scanner (and vice versa).
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
const STYLE_ID = "mms-styles";
|
|
14
|
+
const FLASH_MS = 400;
|
|
15
|
+
const ICONS = {
|
|
16
|
+
cancel: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>`,
|
|
17
|
+
torch: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L4.5 13.5H11L10 22l8.5-11.5H12L13 2z"/></svg>`,
|
|
18
|
+
switch: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8v4h4M21 16v-4h-4"/><path d="M3.5 12a8.5 8.5 0 0 1 14.9-4.5M20.5 12a8.5 8.5 0 0 1-14.9 4.5"/></svg>`,
|
|
19
|
+
};
|
|
20
|
+
const CSS = `
|
|
21
|
+
.mms-stage {
|
|
22
|
+
position: relative;
|
|
23
|
+
overflow: hidden;
|
|
24
|
+
width: 100%;
|
|
25
|
+
height: 100%;
|
|
26
|
+
min-height: 200px;
|
|
27
|
+
background: #000;
|
|
28
|
+
border-radius: var(--mms-radius, 0);
|
|
29
|
+
--mms-dim: rgba(0, 0, 0, 0.55);
|
|
30
|
+
--mms-accent: #3b82f6;
|
|
31
|
+
--mms-success: #22c55e;
|
|
32
|
+
--mms-corner-color: rgba(255, 255, 255, 0.9);
|
|
33
|
+
--mms-corner-size: 26px;
|
|
34
|
+
--mms-corner-width: 4px;
|
|
35
|
+
--mms-frame-size: min(65%, 65vmin);
|
|
36
|
+
--mms-btn-bg: rgba(0, 0, 0, 0.5);
|
|
37
|
+
--mms-btn-fg: #fff;
|
|
38
|
+
}
|
|
39
|
+
.mms-stage[data-theme="light"] {
|
|
40
|
+
--mms-btn-bg: rgba(255, 255, 255, 0.7);
|
|
41
|
+
--mms-btn-fg: #111;
|
|
42
|
+
}
|
|
43
|
+
.mms-video {
|
|
44
|
+
position: absolute;
|
|
45
|
+
inset: 0;
|
|
46
|
+
width: 100%;
|
|
47
|
+
height: 100%;
|
|
48
|
+
object-fit: cover;
|
|
49
|
+
}
|
|
50
|
+
.mms-overlay {
|
|
51
|
+
position: absolute;
|
|
52
|
+
inset: 0;
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
justify-content: center;
|
|
56
|
+
pointer-events: none;
|
|
57
|
+
}
|
|
58
|
+
.mms-frame {
|
|
59
|
+
position: relative;
|
|
60
|
+
width: var(--mms-frame-size);
|
|
61
|
+
aspect-ratio: 1 / 1;
|
|
62
|
+
border-radius: 12px;
|
|
63
|
+
/* the dim around the cutout */
|
|
64
|
+
box-shadow: 0 0 0 200vmax var(--mms-dim);
|
|
65
|
+
transition: box-shadow 0.2s;
|
|
66
|
+
}
|
|
67
|
+
.mms-corner {
|
|
68
|
+
position: absolute;
|
|
69
|
+
width: var(--mms-corner-size);
|
|
70
|
+
height: var(--mms-corner-size);
|
|
71
|
+
border: 0 solid var(--mms-corner-color);
|
|
72
|
+
transition: border-color 0.2s;
|
|
73
|
+
}
|
|
74
|
+
.mms-corner--tl { top: 0; left: 0; border-top-width: var(--mms-corner-width); border-left-width: var(--mms-corner-width); border-top-left-radius: 12px; }
|
|
75
|
+
.mms-corner--tr { top: 0; right: 0; border-top-width: var(--mms-corner-width); border-right-width: var(--mms-corner-width); border-top-right-radius: 12px; }
|
|
76
|
+
.mms-corner--bl { bottom: 0; left: 0; border-bottom-width: var(--mms-corner-width); border-left-width: var(--mms-corner-width); border-bottom-left-radius: 12px; }
|
|
77
|
+
.mms-corner--br { bottom: 0; right: 0; border-bottom-width: var(--mms-corner-width); border-right-width: var(--mms-corner-width); border-bottom-right-radius: 12px; }
|
|
78
|
+
.mms-scanline {
|
|
79
|
+
position: absolute;
|
|
80
|
+
left: 6%;
|
|
81
|
+
right: 6%;
|
|
82
|
+
top: 10%;
|
|
83
|
+
height: 2px;
|
|
84
|
+
border-radius: 1px;
|
|
85
|
+
background: var(--mms-accent);
|
|
86
|
+
box-shadow: 0 0 8px var(--mms-accent);
|
|
87
|
+
opacity: 0;
|
|
88
|
+
}
|
|
89
|
+
.mms-stage[data-status="scanning"] .mms-scanline {
|
|
90
|
+
opacity: 0.9;
|
|
91
|
+
animation: mms-scan 2.2s ease-in-out infinite;
|
|
92
|
+
}
|
|
93
|
+
@keyframes mms-scan {
|
|
94
|
+
0%, 100% { top: 10%; }
|
|
95
|
+
50% { top: calc(90% - 2px); }
|
|
96
|
+
}
|
|
97
|
+
@media (prefers-reduced-motion: reduce) {
|
|
98
|
+
.mms-stage[data-status="scanning"] .mms-scanline { animation: none; top: 49%; }
|
|
99
|
+
}
|
|
100
|
+
.mms-stage.mms-flash .mms-corner { border-color: var(--mms-success); }
|
|
101
|
+
.mms-stage.mms-flash .mms-frame {
|
|
102
|
+
box-shadow: 0 0 0 200vmax var(--mms-dim), inset 0 0 24px var(--mms-success);
|
|
103
|
+
}
|
|
104
|
+
.mms-controls button {
|
|
105
|
+
position: absolute;
|
|
106
|
+
z-index: 2;
|
|
107
|
+
display: inline-flex;
|
|
108
|
+
align-items: center;
|
|
109
|
+
justify-content: center;
|
|
110
|
+
width: 44px;
|
|
111
|
+
height: 44px;
|
|
112
|
+
padding: 10px;
|
|
113
|
+
border: 0;
|
|
114
|
+
border-radius: 50%;
|
|
115
|
+
background: var(--mms-btn-bg);
|
|
116
|
+
color: var(--mms-btn-fg);
|
|
117
|
+
cursor: pointer;
|
|
118
|
+
-webkit-tap-highlight-color: transparent;
|
|
119
|
+
}
|
|
120
|
+
.mms-controls button:active { transform: scale(0.94); }
|
|
121
|
+
.mms-controls button svg { width: 100%; height: 100%; }
|
|
122
|
+
.mms-controls [hidden] { display: none; }
|
|
123
|
+
.mms-btn-cancel { top: 12px; right: 12px; }
|
|
124
|
+
.mms-btn-torch { bottom: 12px; left: 12px; }
|
|
125
|
+
.mms-btn-torch[aria-pressed="true"] {
|
|
126
|
+
background: var(--mms-accent);
|
|
127
|
+
color: #fff;
|
|
128
|
+
}
|
|
129
|
+
.mms-btn-switch { bottom: 12px; right: 12px; }
|
|
130
|
+
`;
|
|
131
|
+
function ensureStyles(doc) {
|
|
132
|
+
// per-document — a stage mounted into an iframe/popup needs the CSS in
|
|
133
|
+
// ITS document, not the opener's
|
|
134
|
+
if (doc.getElementById(STYLE_ID))
|
|
135
|
+
return;
|
|
136
|
+
const style = doc.createElement("style");
|
|
137
|
+
style.id = STYLE_ID;
|
|
138
|
+
style.textContent = CSS;
|
|
139
|
+
doc.head.appendChild(style);
|
|
140
|
+
}
|
|
141
|
+
function resolveTheme(theme) {
|
|
142
|
+
if (theme !== "auto")
|
|
143
|
+
return theme;
|
|
144
|
+
try {
|
|
145
|
+
return globalThis.matchMedia?.("(prefers-color-scheme: dark)")?.matches
|
|
146
|
+
? "dark"
|
|
147
|
+
: "light";
|
|
148
|
+
}
|
|
149
|
+
catch (_e) {
|
|
150
|
+
return "dark";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function setHidden(el, hidden) {
|
|
154
|
+
if (hidden)
|
|
155
|
+
el.setAttribute("hidden", "");
|
|
156
|
+
else
|
|
157
|
+
el.removeAttribute("hidden");
|
|
158
|
+
}
|
|
159
|
+
function button(doc, cls, label, icon) {
|
|
160
|
+
const btn = doc.createElement("button");
|
|
161
|
+
btn.type = "button";
|
|
162
|
+
btn.className = `mms-btn ${cls}`;
|
|
163
|
+
btn.setAttribute("aria-label", label);
|
|
164
|
+
btn.innerHTML = icon;
|
|
165
|
+
return btn;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Mount the scanning stage UI into `options.container`.
|
|
169
|
+
*
|
|
170
|
+
* ```ts
|
|
171
|
+
* const scanner = createScanner();
|
|
172
|
+
* const stage = createScannerStage(scanner, { container: el });
|
|
173
|
+
* const result = await scanner.start();
|
|
174
|
+
* stage.destroy();
|
|
175
|
+
* ```
|
|
176
|
+
*
|
|
177
|
+
* Styling is themable via scoped CSS custom properties (`--mms-*`), e.g.
|
|
178
|
+
* `--mms-accent`, `--mms-dim`, `--mms-frame-size`, `--mms-radius`.
|
|
179
|
+
*/
|
|
180
|
+
export function createScannerStage(scanner, options) {
|
|
181
|
+
const container = options?.container;
|
|
182
|
+
if (!container)
|
|
183
|
+
throw new Error("options.container is required");
|
|
184
|
+
// derive the document from the mount point — supports iframes/popups
|
|
185
|
+
const doc = container.ownerDocument ??
|
|
186
|
+
(typeof document !== "undefined" ? document : null);
|
|
187
|
+
if (!doc)
|
|
188
|
+
throw new Error("createScannerStage requires a DOM environment");
|
|
189
|
+
const video = scanner.getVideo();
|
|
190
|
+
if (!video) {
|
|
191
|
+
throw new Error("Scanner could not provide a video element");
|
|
192
|
+
}
|
|
193
|
+
ensureStyles(doc);
|
|
194
|
+
const controls = { cancel: true, ...options.controls };
|
|
195
|
+
const cleanups = [];
|
|
196
|
+
// --- dom ----------------------------------------------------------------
|
|
197
|
+
const el = doc.createElement("div");
|
|
198
|
+
el.className = "mms-stage";
|
|
199
|
+
el.setAttribute("data-theme", resolveTheme(options.theme ?? "auto"));
|
|
200
|
+
if (options.accent)
|
|
201
|
+
el.style.setProperty("--mms-accent", options.accent);
|
|
202
|
+
video.classList.add("mms-video");
|
|
203
|
+
el.appendChild(video);
|
|
204
|
+
const overlay = doc.createElement("div");
|
|
205
|
+
overlay.className = "mms-overlay";
|
|
206
|
+
const frame = doc.createElement("div");
|
|
207
|
+
frame.className = "mms-frame";
|
|
208
|
+
for (const pos of ["tl", "tr", "bl", "br"]) {
|
|
209
|
+
const corner = doc.createElement("span");
|
|
210
|
+
corner.className = `mms-corner mms-corner--${pos}`;
|
|
211
|
+
frame.appendChild(corner);
|
|
212
|
+
}
|
|
213
|
+
if (options.scanLine ?? true) {
|
|
214
|
+
const line = doc.createElement("div");
|
|
215
|
+
line.className = "mms-scanline";
|
|
216
|
+
frame.appendChild(line);
|
|
217
|
+
}
|
|
218
|
+
overlay.appendChild(frame);
|
|
219
|
+
el.appendChild(overlay);
|
|
220
|
+
const controlsEl = doc.createElement("div");
|
|
221
|
+
controlsEl.className = "mms-controls";
|
|
222
|
+
let cancelBtn = null;
|
|
223
|
+
let torchBtn = null;
|
|
224
|
+
let switchBtn = null;
|
|
225
|
+
if (controls.cancel) {
|
|
226
|
+
cancelBtn = button(doc, "mms-btn-cancel", "Cancel scanning", ICONS.cancel);
|
|
227
|
+
cancelBtn.addEventListener("click", () => {
|
|
228
|
+
scanner.stop();
|
|
229
|
+
options.onCancel?.();
|
|
230
|
+
});
|
|
231
|
+
controlsEl.appendChild(cancelBtn);
|
|
232
|
+
}
|
|
233
|
+
if (controls.torch) {
|
|
234
|
+
torchBtn = button(doc, "mms-btn-torch", "Toggle flashlight", ICONS.torch);
|
|
235
|
+
setHidden(torchBtn, true);
|
|
236
|
+
torchBtn.addEventListener("click", () => {
|
|
237
|
+
void scanner.setTorch(!scanner.get().torch.on);
|
|
238
|
+
});
|
|
239
|
+
controlsEl.appendChild(torchBtn);
|
|
240
|
+
}
|
|
241
|
+
if (controls.cameraSwitch) {
|
|
242
|
+
switchBtn = button(doc, "mms-btn-switch", "Switch camera", ICONS.switch);
|
|
243
|
+
setHidden(switchBtn, true);
|
|
244
|
+
switchBtn.addEventListener("click", () => {
|
|
245
|
+
const { cameras, activeCameraId } = scanner.get();
|
|
246
|
+
if (cameras.length < 2)
|
|
247
|
+
return;
|
|
248
|
+
const idx = cameras.findIndex((c) => c.deviceId === activeCameraId);
|
|
249
|
+
const next = cameras[(idx + 1) % cameras.length];
|
|
250
|
+
void scanner.setCamera(next.deviceId);
|
|
251
|
+
});
|
|
252
|
+
controlsEl.appendChild(switchBtn);
|
|
253
|
+
}
|
|
254
|
+
el.appendChild(controlsEl);
|
|
255
|
+
// --- theme (auto) ---------------------------------------------------------
|
|
256
|
+
if ((options.theme ?? "auto") === "auto") {
|
|
257
|
+
try {
|
|
258
|
+
const mq = globalThis.matchMedia?.("(prefers-color-scheme: dark)");
|
|
259
|
+
const onChange = () => {
|
|
260
|
+
el.setAttribute("data-theme", mq?.matches ? "dark" : "light");
|
|
261
|
+
};
|
|
262
|
+
mq?.addEventListener?.("change", onChange);
|
|
263
|
+
if (mq)
|
|
264
|
+
cleanups.push(() => mq.removeEventListener?.("change", onChange));
|
|
265
|
+
}
|
|
266
|
+
catch (_e) {
|
|
267
|
+
/* noop */
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// --- reactive wiring ------------------------------------------------------
|
|
271
|
+
let flashTimer = null;
|
|
272
|
+
let lastFlashTs = 0;
|
|
273
|
+
const unsub = scanner.subscribe((state) => {
|
|
274
|
+
el.setAttribute("data-status", state.status);
|
|
275
|
+
if (torchBtn) {
|
|
276
|
+
setHidden(torchBtn, !state.torch.supported);
|
|
277
|
+
torchBtn.setAttribute("aria-pressed", String(state.torch.on));
|
|
278
|
+
}
|
|
279
|
+
if (switchBtn)
|
|
280
|
+
setHidden(switchBtn, state.cameras.length < 2);
|
|
281
|
+
if (state.lastResult && state.lastResult.timestamp !== lastFlashTs) {
|
|
282
|
+
lastFlashTs = state.lastResult.timestamp;
|
|
283
|
+
el.classList.add("mms-flash");
|
|
284
|
+
if (flashTimer)
|
|
285
|
+
clearTimeout(flashTimer);
|
|
286
|
+
flashTimer = setTimeout(() => el.classList.remove("mms-flash"), FLASH_MS);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
cleanups.push(unsub);
|
|
290
|
+
cleanups.push(() => {
|
|
291
|
+
if (flashTimer)
|
|
292
|
+
clearTimeout(flashTimer);
|
|
293
|
+
});
|
|
294
|
+
// --- mount ----------------------------------------------------------------
|
|
295
|
+
// the stage is absolutely positioned inside the container — make sure the
|
|
296
|
+
// container establishes a positioning context (computed style respected,
|
|
297
|
+
// reverted on destroy if we set it)
|
|
298
|
+
let containerPositionSet = false;
|
|
299
|
+
try {
|
|
300
|
+
const computed = globalThis.getComputedStyle?.(container)?.position;
|
|
301
|
+
const pos = computed ?? container.style?.position;
|
|
302
|
+
if (!pos || pos === "static") {
|
|
303
|
+
container.style.position = "relative";
|
|
304
|
+
containerPositionSet = true;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch (_e) {
|
|
308
|
+
/* non-browser DOM without style support — leave positioning to the app */
|
|
309
|
+
}
|
|
310
|
+
container.appendChild(el);
|
|
311
|
+
return {
|
|
312
|
+
el,
|
|
313
|
+
destroy() {
|
|
314
|
+
cleanups.forEach((fn) => {
|
|
315
|
+
try {
|
|
316
|
+
fn();
|
|
317
|
+
}
|
|
318
|
+
catch (_e) {
|
|
319
|
+
/* noop */
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
cleanups.length = 0;
|
|
323
|
+
if (containerPositionSet) {
|
|
324
|
+
try {
|
|
325
|
+
container.style.position = "";
|
|
326
|
+
}
|
|
327
|
+
catch (_e) {
|
|
328
|
+
/* noop */
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
el.remove();
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type { Unsubscribe } from "@marianmeres/store";
|
|
2
|
+
/**
|
|
3
|
+
* Barcode formats supported by the underlying zxing-cpp engine (via
|
|
4
|
+
* `barcode-detector`). The default is `["qr_code"]` — fewer enabled formats
|
|
5
|
+
* means faster per-frame decode and fewer false positives.
|
|
6
|
+
*
|
|
7
|
+
* The special values `"linear_codes"`, `"matrix_codes"` and `"any"` are
|
|
8
|
+
* engine-level shortcuts expanding to the respective format groups.
|
|
9
|
+
*/
|
|
10
|
+
export type BarcodeFormat = "aztec" | "codabar" | "code_39" | "code_93" | "code_128" | "data_matrix" | "databar" | "databar_expanded" | "databar_limited" | "dx_film_edge" | "ean_8" | "ean_13" | "itf" | "maxi_code" | "micro_qr_code" | "pdf417" | "qr_code" | "rm_qr_code" | "upc_a" | "upc_e" | "linear_codes" | "matrix_codes" | "any" | (string & Record<never, never>);
|
|
11
|
+
/** A 2D point in source-image coordinates. */
|
|
12
|
+
export interface Point2D {
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
}
|
|
16
|
+
/** Camera permission status (mirrors the micperms taxonomy). */
|
|
17
|
+
export type CameraPermissionStatus = "unknown" | "prompt" | "granted" | "denied";
|
|
18
|
+
/** Camera facing direction (best-effort, derived from device label). */
|
|
19
|
+
export type CameraFacing = "user" | "environment";
|
|
20
|
+
/** Info about an available video input device. */
|
|
21
|
+
export interface CameraInfo {
|
|
22
|
+
/** Device id usable with {@linkcode Scanner.setCamera}. */
|
|
23
|
+
deviceId: string;
|
|
24
|
+
/**
|
|
25
|
+
* Human readable label. NOTE: browsers return empty labels until
|
|
26
|
+
* camera permission has been granted at least once.
|
|
27
|
+
*/
|
|
28
|
+
label: string;
|
|
29
|
+
/** Best-effort facing direction derived from the label, or `null`. */
|
|
30
|
+
facing: CameraFacing | null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Machine-readable error codes attached to {@linkcode ScannerState.error}.
|
|
34
|
+
*
|
|
35
|
+
* - `NO_DEVICE` — getUserMedia threw `NotFoundError` / `DevicesNotFoundError`.
|
|
36
|
+
* No camera is available.
|
|
37
|
+
* - `INSECURE_CONTEXT` — getUserMedia threw `SecurityError`. Origin is not
|
|
38
|
+
* secure (camera requires HTTPS) or a Permissions-Policy blocks the API.
|
|
39
|
+
* - `DEVICE_BUSY` — getUserMedia threw `NotReadableError` / `TrackStartError`.
|
|
40
|
+
* Hardware is held by another consumer.
|
|
41
|
+
* - `PERMISSION_DENIED` — getUserMedia threw `NotAllowedError` /
|
|
42
|
+
* `PermissionDeniedError`. The user (or platform policy) denied camera
|
|
43
|
+
* access; `state.permission` is set to `"denied"` as well.
|
|
44
|
+
* - `REQUEST_FAILED` — camera acquisition failed with a non-classified error,
|
|
45
|
+
* or the live camera track ended unexpectedly (permission revoked
|
|
46
|
+
* mid-scan, device unplugged, camera preempted by a native app).
|
|
47
|
+
* - `NOT_SUPPORTED` — required platform API is missing (no `mediaDevices`,
|
|
48
|
+
* no DOM for the video element, ...).
|
|
49
|
+
* - `DETECTOR_FAILED` — the barcode detector threw repeatedly.
|
|
50
|
+
*/
|
|
51
|
+
export declare const ScannerErrorCode: {
|
|
52
|
+
readonly NoDevice: "NO_DEVICE";
|
|
53
|
+
readonly InsecureContext: "INSECURE_CONTEXT";
|
|
54
|
+
readonly DeviceBusy: "DEVICE_BUSY";
|
|
55
|
+
readonly PermissionDenied: "PERMISSION_DENIED";
|
|
56
|
+
readonly RequestFailed: "REQUEST_FAILED";
|
|
57
|
+
readonly NotSupported: "NOT_SUPPORTED";
|
|
58
|
+
readonly DetectorFailed: "DETECTOR_FAILED";
|
|
59
|
+
};
|
|
60
|
+
export type ScannerErrorCode = typeof ScannerErrorCode[keyof typeof ScannerErrorCode];
|
|
61
|
+
/** Error attached to {@linkcode ScannerState.error}. */
|
|
62
|
+
export interface ScannerError {
|
|
63
|
+
/** Machine-readable error code. See {@linkcode ScannerErrorCode}. */
|
|
64
|
+
code: ScannerErrorCode;
|
|
65
|
+
/** Human-readable error message (typically forwarded from underlying API). */
|
|
66
|
+
message: string;
|
|
67
|
+
}
|
|
68
|
+
/** Scanner lifecycle status. */
|
|
69
|
+
export type ScannerStatus = "idle" | "initializing" | "scanning" | "stopped";
|
|
70
|
+
/** A single successful barcode detection. */
|
|
71
|
+
export interface ScanResult {
|
|
72
|
+
/** The decoded payload. */
|
|
73
|
+
value: string;
|
|
74
|
+
/** Detected barcode format (e.g. `"qr_code"`). */
|
|
75
|
+
format: BarcodeFormat;
|
|
76
|
+
/** Corner points of the detected symbol in source coordinates. */
|
|
77
|
+
cornerPoints: Point2D[];
|
|
78
|
+
/** Bounding box of the detected symbol in source coordinates. */
|
|
79
|
+
boundingBox: DOMRectReadOnly;
|
|
80
|
+
/** Detection timestamp (`Date.now()`). */
|
|
81
|
+
timestamp: number;
|
|
82
|
+
}
|
|
83
|
+
/** Reactive state of the scanner. */
|
|
84
|
+
export interface ScannerState {
|
|
85
|
+
/** Current lifecycle status. */
|
|
86
|
+
status: ScannerStatus;
|
|
87
|
+
/** Last error, or `null`. Methods never throw — errors land here. */
|
|
88
|
+
error: ScannerError | null;
|
|
89
|
+
/**
|
|
90
|
+
* Camera permission status, best effort (Permissions API where available,
|
|
91
|
+
* otherwise updated from getUserMedia outcomes).
|
|
92
|
+
*/
|
|
93
|
+
permission: CameraPermissionStatus;
|
|
94
|
+
/** Torch (flashlight) capability + current state of the active track. */
|
|
95
|
+
torch: {
|
|
96
|
+
supported: boolean;
|
|
97
|
+
on: boolean;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Available cameras. Populated on {@linkcode Scanner.listCameras} and
|
|
101
|
+
* after successful {@linkcode Scanner.start} (labels require permission).
|
|
102
|
+
*/
|
|
103
|
+
cameras: CameraInfo[];
|
|
104
|
+
/** Device id of the currently active camera, or `null`. */
|
|
105
|
+
activeCameraId: string | null;
|
|
106
|
+
/** Most recent successful detection, or `null`. */
|
|
107
|
+
lastResult: ScanResult | null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The raw detection shape produced by a {@linkcode Detector} — structurally
|
|
111
|
+
* compatible with the W3C `DetectedBarcode`.
|
|
112
|
+
*/
|
|
113
|
+
export interface DetectedBarcodeLike {
|
|
114
|
+
rawValue: string;
|
|
115
|
+
format: string;
|
|
116
|
+
cornerPoints: Point2D[];
|
|
117
|
+
boundingBox: DOMRectReadOnly;
|
|
118
|
+
}
|
|
119
|
+
/** Anything a {@linkcode Detector} can decode. */
|
|
120
|
+
export type DetectorSource = HTMLVideoElement | HTMLImageElement | HTMLCanvasElement | ImageBitmap | ImageData | Blob;
|
|
121
|
+
/**
|
|
122
|
+
* Abstraction over the barcode decoding engine — shaped like the W3C
|
|
123
|
+
* `BarcodeDetector`. Injectable for testing or engine swaps. The default
|
|
124
|
+
* implementation wraps the `barcode-detector` ponyfill (zxing-cpp wasm).
|
|
125
|
+
*/
|
|
126
|
+
export interface Detector {
|
|
127
|
+
detect(source: DetectorSource): Promise<DetectedBarcodeLike[]>;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Abstraction over camera acquisition. Injectable for testing or replacement.
|
|
131
|
+
* The default implementation wraps `navigator.mediaDevices` for stream
|
|
132
|
+
* acquisition/enumeration and delegates the permission lifecycle to
|
|
133
|
+
* `@marianmeres/mediaperms`.
|
|
134
|
+
*
|
|
135
|
+
* NOTE (difference vs micperms): the scanner RETAINS the acquired stream for
|
|
136
|
+
* the live preview — the adapter must return a live `MediaStream`, the
|
|
137
|
+
* scanner is responsible for stopping its tracks.
|
|
138
|
+
*/
|
|
139
|
+
export interface CameraAdapter {
|
|
140
|
+
/** Acquire a camera stream. May prompt. Rejections are classified by the scanner. */
|
|
141
|
+
getStream(constraints: MediaStreamConstraints): Promise<MediaStream>;
|
|
142
|
+
/** List available video input devices. */
|
|
143
|
+
enumerateVideoDevices(): Promise<CameraInfo[]>;
|
|
144
|
+
/**
|
|
145
|
+
* Query camera permission via the Permissions API without prompting.
|
|
146
|
+
* Returns `null` when the Permissions API is unavailable/unsupported.
|
|
147
|
+
*/
|
|
148
|
+
queryPermission(): Promise<CameraPermissionStatus | null>;
|
|
149
|
+
/**
|
|
150
|
+
* Subscribe to permission changes. Returns an unsubscribe fn, or `null`
|
|
151
|
+
* when not supported.
|
|
152
|
+
*/
|
|
153
|
+
onPermissionChange(cb: (status: CameraPermissionStatus) => void): (() => void) | null;
|
|
154
|
+
/**
|
|
155
|
+
* Release adapter-owned resources (e.g. an internally created mediaperms
|
|
156
|
+
* instance). Called by {@linkcode Scanner.destroy} ONLY when the scanner
|
|
157
|
+
* created the adapter itself — a consumer-provided adapter is owned (and
|
|
158
|
+
* destroyed) by the consumer.
|
|
159
|
+
*/
|
|
160
|
+
destroy?(): void;
|
|
161
|
+
}
|
|
162
|
+
/** Scanning mode. See {@linkcode ScannerConfig.mode}. */
|
|
163
|
+
export type ScanMode = "single" | "continuous";
|
|
164
|
+
/**
|
|
165
|
+
* Logger shape — method-only, so both `@marianmeres/clog` instances and the
|
|
166
|
+
* plain `console` satisfy it.
|
|
167
|
+
*/
|
|
168
|
+
export interface LoggerLike {
|
|
169
|
+
debug: (...args: unknown[]) => unknown;
|
|
170
|
+
warn: (...args: unknown[]) => unknown;
|
|
171
|
+
error: (...args: unknown[]) => unknown;
|
|
172
|
+
}
|
|
173
|
+
/** Configuration for {@linkcode createScanner}. */
|
|
174
|
+
export interface ScannerConfig {
|
|
175
|
+
/**
|
|
176
|
+
* Video element to play the camera stream in. Created lazily when omitted
|
|
177
|
+
* (requires DOM). The shipped stage UI uses {@linkcode Scanner.getVideo}.
|
|
178
|
+
*/
|
|
179
|
+
video?: HTMLVideoElement;
|
|
180
|
+
/** Formats to detect. Default `["qr_code"]`. */
|
|
181
|
+
formats?: BarcodeFormat[];
|
|
182
|
+
/**
|
|
183
|
+
* - `"single"` (default): auto-stop after the first detection, which is
|
|
184
|
+
* passed to `onScan` and resolves the `start()` promise.
|
|
185
|
+
* - `"continuous"`: keep scanning; every detection fires `onScan`
|
|
186
|
+
* (deduped within {@linkcode ScannerConfig.dedupeMs}); `start()`
|
|
187
|
+
* resolves `null` when stopped.
|
|
188
|
+
*/
|
|
189
|
+
mode?: ScanMode;
|
|
190
|
+
/** Called for every successful detection. */
|
|
191
|
+
onScan?: (result: ScanResult) => void;
|
|
192
|
+
/** Called whenever an error is set on the state. */
|
|
193
|
+
onError?: (error: ScannerError) => void;
|
|
194
|
+
/**
|
|
195
|
+
* `"environment"` (default) / `"user"` facing hint, or a concrete
|
|
196
|
+
* `deviceId`.
|
|
197
|
+
*/
|
|
198
|
+
preferredCamera?: CameraFacing | (string & Record<never, never>);
|
|
199
|
+
/** Minimal interval between decode attempts in ms. Default `100` (~10 fps). */
|
|
200
|
+
scanIntervalMs?: number;
|
|
201
|
+
/**
|
|
202
|
+
* Continuous mode: suppress repeated detections of the identical value
|
|
203
|
+
* within this window (ms). Default `1500`.
|
|
204
|
+
*/
|
|
205
|
+
dedupeMs?: number;
|
|
206
|
+
/** Camera acquisition seam. Default wraps `navigator.mediaDevices`. */
|
|
207
|
+
adapter?: CameraAdapter;
|
|
208
|
+
/** Decoding engine seam. Default wraps the `barcode-detector` ponyfill. */
|
|
209
|
+
detector?: Detector;
|
|
210
|
+
/**
|
|
211
|
+
* Overrides passed to zxing-wasm's `prepareZXingModule` — most notably
|
|
212
|
+
* `locateFile` for self-hosting the `.wasm` binary (CSP-strict or offline
|
|
213
|
+
* apps). By default the wasm is fetched lazily from the jsDelivr CDN.
|
|
214
|
+
*
|
|
215
|
+
* ```ts
|
|
216
|
+
* wasmOverrides: { locateFile: (path, prefix) => `/assets/${path}` }
|
|
217
|
+
* ```
|
|
218
|
+
*/
|
|
219
|
+
wasmOverrides?: Record<string, unknown>;
|
|
220
|
+
/** Custom logger (`@marianmeres/clog` compatible). */
|
|
221
|
+
logger?: LoggerLike;
|
|
222
|
+
}
|
|
223
|
+
/** The scanner instance created by {@linkcode createScanner}. */
|
|
224
|
+
export interface Scanner {
|
|
225
|
+
/**
|
|
226
|
+
* Subscribe to reactive state. Svelte store contract — the callback fires
|
|
227
|
+
* immediately with the current state. Returns an idempotent unsubscribe fn
|
|
228
|
+
* that also implements `Symbol.dispose` (usable with `using`).
|
|
229
|
+
*/
|
|
230
|
+
subscribe(cb: (state: ScannerState) => void): Unsubscribe;
|
|
231
|
+
/** Get current state snapshot. */
|
|
232
|
+
get(): ScannerState;
|
|
233
|
+
/**
|
|
234
|
+
* Acquire the camera and start scanning. Never rejects.
|
|
235
|
+
*
|
|
236
|
+
* - `"single"` mode: resolves with the first {@linkcode ScanResult}, or
|
|
237
|
+
* `null` when cancelled via {@linkcode Scanner.stop} (or on failure —
|
|
238
|
+
* check `state.error`).
|
|
239
|
+
* - `"continuous"` mode: resolves `null` when stopped.
|
|
240
|
+
*
|
|
241
|
+
* Concurrent calls while active return the same in-flight promise.
|
|
242
|
+
*/
|
|
243
|
+
start(): Promise<ScanResult | null>;
|
|
244
|
+
/** Cancel: stop the loop, release the camera. Resolves pending `start()` with `null`. */
|
|
245
|
+
stop(): void;
|
|
246
|
+
/** List available cameras (also updates `state.cameras`). */
|
|
247
|
+
listCameras(): Promise<CameraInfo[]>;
|
|
248
|
+
/** Switch to another camera (live-switch while scanning is supported). */
|
|
249
|
+
setCamera(deviceId: string): Promise<void>;
|
|
250
|
+
/**
|
|
251
|
+
* Turn the torch (flashlight) on/off. Resolves `true` on success, `false`
|
|
252
|
+
* when unsupported or failed (never rejects).
|
|
253
|
+
*/
|
|
254
|
+
setTorch(on: boolean): Promise<boolean>;
|
|
255
|
+
/**
|
|
256
|
+
* The video element used for the live preview. Lazily created when none
|
|
257
|
+
* was configured (returns `null` in non-DOM environments).
|
|
258
|
+
*/
|
|
259
|
+
getVideo(): HTMLVideoElement | null;
|
|
260
|
+
/** Stop everything and release all resources/listeners. Idempotent. */
|
|
261
|
+
destroy(): void;
|
|
262
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine-readable error codes attached to {@linkcode ScannerState.error}.
|
|
3
|
+
*
|
|
4
|
+
* - `NO_DEVICE` — getUserMedia threw `NotFoundError` / `DevicesNotFoundError`.
|
|
5
|
+
* No camera is available.
|
|
6
|
+
* - `INSECURE_CONTEXT` — getUserMedia threw `SecurityError`. Origin is not
|
|
7
|
+
* secure (camera requires HTTPS) or a Permissions-Policy blocks the API.
|
|
8
|
+
* - `DEVICE_BUSY` — getUserMedia threw `NotReadableError` / `TrackStartError`.
|
|
9
|
+
* Hardware is held by another consumer.
|
|
10
|
+
* - `PERMISSION_DENIED` — getUserMedia threw `NotAllowedError` /
|
|
11
|
+
* `PermissionDeniedError`. The user (or platform policy) denied camera
|
|
12
|
+
* access; `state.permission` is set to `"denied"` as well.
|
|
13
|
+
* - `REQUEST_FAILED` — camera acquisition failed with a non-classified error,
|
|
14
|
+
* or the live camera track ended unexpectedly (permission revoked
|
|
15
|
+
* mid-scan, device unplugged, camera preempted by a native app).
|
|
16
|
+
* - `NOT_SUPPORTED` — required platform API is missing (no `mediaDevices`,
|
|
17
|
+
* no DOM for the video element, ...).
|
|
18
|
+
* - `DETECTOR_FAILED` — the barcode detector threw repeatedly.
|
|
19
|
+
*/
|
|
20
|
+
export const ScannerErrorCode = {
|
|
21
|
+
NoDevice: "NO_DEVICE",
|
|
22
|
+
InsecureContext: "INSECURE_CONTEXT",
|
|
23
|
+
DeviceBusy: "DEVICE_BUSY",
|
|
24
|
+
PermissionDenied: "PERMISSION_DENIED",
|
|
25
|
+
RequestFailed: "REQUEST_FAILED",
|
|
26
|
+
NotSupported: "NOT_SUPPORTED",
|
|
27
|
+
DetectorFailed: "DETECTOR_FAILED",
|
|
28
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@marianmeres/scanner",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/mod.js",
|
|
6
|
+
"types": "dist/mod.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/mod.d.ts",
|
|
10
|
+
"import": "./dist/mod.js"
|
|
11
|
+
},
|
|
12
|
+
"./stage": {
|
|
13
|
+
"types": "./dist/stage.d.ts",
|
|
14
|
+
"import": "./dist/stage.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"README.md",
|
|
21
|
+
"API.md",
|
|
22
|
+
"AGENTS.md",
|
|
23
|
+
"CLAUDE.md"
|
|
24
|
+
],
|
|
25
|
+
"author": "Marian Meres",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@marianmeres/clog": "^3.21.0",
|
|
29
|
+
"@marianmeres/mediaperms": "^1.2.0",
|
|
30
|
+
"@marianmeres/store": "^3.0.1",
|
|
31
|
+
"barcode-detector": "^3.2.1"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/marianmeres/scanner.git"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/marianmeres/scanner/issues"
|
|
39
|
+
}
|
|
40
|
+
}
|