@smileid/web-components 11.5.0 → 11.6.0
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/dist/esm/DocumentCaptureScreens-DjSTdVP-.js +5398 -0
- package/dist/esm/DocumentCaptureScreens-DjSTdVP-.js.map +1 -0
- package/dist/esm/{Navigation-Xg565kcu.js → Navigation-6DH3vF4-.js} +2 -2
- package/dist/esm/{Navigation-Xg565kcu.js.map → Navigation-6DH3vF4-.js.map} +1 -1
- package/dist/esm/{PoweredBySmileId-CxbaihMu.js → PoweredBySmileId-DoKwoPUd.js} +424 -6
- package/dist/esm/PoweredBySmileId-DoKwoPUd.js.map +1 -0
- package/dist/esm/{SelfieCaptureScreens-D3KuMzZA.js → SelfieCaptureScreens-CtX-4Tco.js} +5 -6
- package/dist/esm/SelfieCaptureScreens-CtX-4Tco.js.map +1 -0
- package/dist/esm/combobox.js +1 -1
- package/dist/esm/document.js +1 -1
- package/dist/esm/end-user-consent.js +713 -2
- package/dist/esm/end-user-consent.js.map +1 -1
- package/dist/esm/index-BqyuTk9f.js +1366 -0
- package/dist/esm/{index-CUwa6MPI.js.map → index-BqyuTk9f.js.map} +1 -1
- package/dist/esm/localisation.js +1 -1
- package/dist/esm/main.js +14 -14
- package/dist/esm/navigation.js +1 -1
- package/dist/esm/{package-BmVbDNny.js → package-CjZI-cNQ.js} +177 -172
- package/dist/esm/{package-BmVbDNny.js.map → package-CjZI-cNQ.js.map} +1 -1
- package/dist/esm/selfie.js +1 -1
- package/dist/esm/smart-camera-web.js +32 -18
- package/dist/esm/smart-camera-web.js.map +1 -1
- package/dist/esm/totp-consent.js +731 -2
- package/dist/esm/totp-consent.js.map +1 -1
- package/dist/esm/validate.js +31 -0
- package/dist/esm/validate.js.map +1 -0
- package/dist/smart-camera-web.js +696 -321
- package/dist/smart-camera-web.js.map +1 -1
- package/dist/types/main.d.ts +7 -1
- package/dist/types/validate.d.ts +21 -0
- package/lib/components/document/src/DocumentCaptureScreens.js +97 -18
- package/lib/components/document/src/assets/lottie.d.ts +12 -0
- package/lib/components/document/src/assets/svg-inline.d.ts +8 -0
- package/lib/components/document/src/document-auto-capture/DocumentAutoCapture.stories.js +75 -0
- package/lib/components/document/src/document-auto-capture/DocumentAutoCapture.tsx +1458 -0
- package/lib/components/document/src/document-auto-capture/README.md +73 -0
- package/lib/components/document/src/document-auto-capture/assets/Greenbook_Shimmer.svg +42 -0
- package/lib/components/document/src/document-auto-capture/assets/ID_Back_Shimmer.svg +8 -0
- package/lib/components/document/src/document-auto-capture/assets/ID_Front_Shimmer.svg +20 -0
- package/lib/components/document/src/document-auto-capture/assets/Passport-Shimmer.svg +143 -0
- package/lib/components/document/src/document-auto-capture/assets/shimmers.ts +21 -0
- package/lib/components/document/src/document-auto-capture/assets/svg-raw.d.ts +4 -0
- package/lib/components/document/src/document-auto-capture/components/CaptureButton.tsx +122 -0
- package/lib/components/document/src/document-auto-capture/components/Overlay.tsx +167 -0
- package/lib/components/document/src/document-auto-capture/components/TuningPanel.tsx +856 -0
- package/lib/components/document/src/document-auto-capture/constants/captureLayout.ts +58 -0
- package/lib/components/document/src/document-auto-capture/detection/cvErrorRecovery.ts +40 -0
- package/lib/components/document/src/document-auto-capture/detection/documentAspect.ts +20 -0
- package/lib/components/document/src/document-auto-capture/detection/qualityScoring.ts +35 -0
- package/lib/components/document/src/document-auto-capture/detection/seamRejection.ts +209 -0
- package/lib/components/document/src/document-auto-capture/detection/synthesisTiming.ts +10 -0
- package/lib/components/document/src/document-auto-capture/hooks/useCamera.ts +117 -0
- package/lib/components/document/src/document-auto-capture/hooks/useCardDetection.ts +3059 -0
- package/lib/components/document/src/document-auto-capture/index.ts +4 -0
- package/lib/components/document/src/document-auto-capture/theme.ts +40 -0
- package/lib/components/document/src/document-auto-capture/utils/debug.ts +25 -0
- package/lib/components/document/src/document-auto-capture/utils/opencvLoader.ts +86 -0
- package/lib/components/document/src/document-capture-instructions/DocumentCaptureInstructions.tsx +327 -244
- package/lib/components/document/src/document-capture-review/DocumentCaptureReview.js +153 -189
- package/lib/components/document/src/document-capture-submission/DocumentCaptureSubmission.tsx +432 -0
- package/lib/components/document/src/document-capture-submission/index.js +3 -0
- package/lib/components/selfie/README.md +13 -0
- package/lib/components/signature-pad/package.json +1 -1
- package/lib/components/smart-camera-web/src/README.md +11 -0
- package/lib/components/smart-camera-web/src/SmartCameraWeb.js +25 -1
- package/lib/components/totp-consent/src/TotpConsent.js +1 -1
- package/package.json +8 -4
- package/dist/esm/DocumentCaptureScreens-ucJDu5nH.js +0 -2232
- package/dist/esm/DocumentCaptureScreens-ucJDu5nH.js.map +0 -1
- package/dist/esm/EndUserConsent-CsiwoThZ.js +0 -717
- package/dist/esm/EndUserConsent-CsiwoThZ.js.map +0 -1
- package/dist/esm/PoweredBySmileId-CxbaihMu.js.map +0 -1
- package/dist/esm/SelfieCaptureScreens-D3KuMzZA.js.map +0 -1
- package/dist/esm/TotpConsent-CRtmtudl.js +0 -734
- package/dist/esm/TotpConsent-CRtmtudl.js.map +0 -1
- package/dist/esm/index-CUwa6MPI.js +0 -1363
- package/dist/esm/styles-BTEClL7R.js +0 -419
- package/dist/esm/styles-BTEClL7R.js.map +0 -1
- /package/lib/components/document/src/assets/lottie/{taking photo of green book passport.lottie → greenbook.lottie} +0 -0
- /package/lib/components/document/src/assets/lottie/{taking photo of ID FLIP 2D.lottie → id-card-flip.lottie} +0 -0
- /package/lib/components/document/src/assets/lottie/{taking photo of ID.lottie → id-card.lottie} +0 -0
- /package/lib/components/document/src/assets/lottie/{taking photo of passport 2.lottie → passport.lottie} +0 -0
|
@@ -0,0 +1,1458 @@
|
|
|
1
|
+
import { useState, useEffect, useLayoutEffect, useRef } from 'preact/hooks';
|
|
2
|
+
import register from 'preact-custom-element';
|
|
3
|
+
import type { FunctionComponent } from 'preact';
|
|
4
|
+
|
|
5
|
+
import { useCamera } from './hooks/useCamera';
|
|
6
|
+
import { useCardDetection, COMPLIANCE_STATES } from './hooks/useCardDetection';
|
|
7
|
+
import { Overlay } from './components/Overlay';
|
|
8
|
+
import { CaptureButton } from './components/CaptureButton';
|
|
9
|
+
import { TuningPanel } from './components/TuningPanel';
|
|
10
|
+
import { ensureOpenCv } from './utils/opencvLoader';
|
|
11
|
+
import { isDebugEnabled } from './utils/debug';
|
|
12
|
+
import { theme } from './theme';
|
|
13
|
+
import { translate } from '../../../../domain/localisation';
|
|
14
|
+
|
|
15
|
+
import '../../../navigation/src';
|
|
16
|
+
|
|
17
|
+
import { getBoolProp } from '../../../../utils/props';
|
|
18
|
+
import { JPEG_QUALITY } from '../../../../domain/constants/src/Constants';
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
'document-type'?: string;
|
|
22
|
+
'auto-capture'?: 'autoCapture' | 'autoCaptureOnly' | 'manualCaptureOnly';
|
|
23
|
+
'auto-capture-timeout'?: string | number;
|
|
24
|
+
'side-of-id'?: 'Front' | 'Back' | string;
|
|
25
|
+
'show-navigation'?: string | boolean;
|
|
26
|
+
'allow-gallery-upload'?: string | boolean;
|
|
27
|
+
'document-capture-modes'?: string;
|
|
28
|
+
'sync-roi-to-guide'?: string | boolean;
|
|
29
|
+
'theme-color'?: string;
|
|
30
|
+
title?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type CaptureMode = 'autoCapture' | 'autoCaptureOnly' | 'manualCaptureOnly';
|
|
34
|
+
const CAPTURE_MODES: CaptureMode[] = [
|
|
35
|
+
'autoCapture',
|
|
36
|
+
'autoCaptureOnly',
|
|
37
|
+
'manualCaptureOnly',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// Settings shared by both device profiles. These are device-independent —
|
|
41
|
+
// shape/colour gates and crop geometry behave the same on a phone and a webcam,
|
|
42
|
+
// so they live in one place to stop the two profiles from drifting apart.
|
|
43
|
+
const SHARED_DEFAULTS = {
|
|
44
|
+
// Adaptive contour-Canny sensitivity. The high threshold is
|
|
45
|
+
// mean + autoCannySigma·stddev of the frame's gradient magnitude, clamped to
|
|
46
|
+
// [60, 150]. Lower = detect fainter borders (better on plain backgrounds),
|
|
47
|
+
// higher = stricter (fewer false edges on busy backgrounds).
|
|
48
|
+
autoCannySigma: 1.0,
|
|
49
|
+
chromaCannyLow: 15,
|
|
50
|
+
chromaCannyHigh: 40,
|
|
51
|
+
// Mobile content-region fallback OFF by default on both profiles. The
|
|
52
|
+
// tilt-robust real-contour path (minAreaRect) + chroma fusion detect cards
|
|
53
|
+
// directly, so the looser synthetic fallback is mostly a false-positive
|
|
54
|
+
// source. Re-enable live via the panel to compare.
|
|
55
|
+
mobileRegionFallback: false,
|
|
56
|
+
// False-positive controls. minAreaRect gives the card's true aspect, so a
|
|
57
|
+
// tight id-card window (1.585 ± 12% = [1.395, 1.775]) excludes 16:9 screens
|
|
58
|
+
// and most non-card rectangles. Passport/greenbook window (1.42 ± 10% =
|
|
59
|
+
// [1.278, 1.562]) is tight enough to exclude ID cards (1.585), monitors and
|
|
60
|
+
// phones. minFillRatio rejects ragged quads (rotated-rect fill, tilt-invariant).
|
|
61
|
+
idAspectTolerance: 0.12,
|
|
62
|
+
bookDocAspectTolerance: 0.1,
|
|
63
|
+
minFillRatio: 0.8,
|
|
64
|
+
minChromaContent: 13,
|
|
65
|
+
// Seam rejection: reject a card-shaped quad whose edges sit on long straight
|
|
66
|
+
// background lines that overshoot its corners (parquet floor, slatted table),
|
|
67
|
+
// detected via HoughLinesP on the contour edge map. Only ADDS rejections —
|
|
68
|
+
// off, or with no through-lines present, detection is unchanged. houghThreshold
|
|
69
|
+
// is the accumulator vote count; houghMinLengthRatio is the minimum line length
|
|
70
|
+
// as a fraction of the smaller ROI side; houghMaxLineGap bridges dashed edges.
|
|
71
|
+
seamRejectEnabled: true,
|
|
72
|
+
houghThreshold: 40,
|
|
73
|
+
houghMinLengthRatio: 0.3,
|
|
74
|
+
houghMaxLineGap: 10,
|
|
75
|
+
// Clutter guard: skip seam-rejection when HoughLinesP returns more lines than
|
|
76
|
+
// this — a woven fabric/carpet floods the map (~400+) and would falsely reject
|
|
77
|
+
// a real card, whereas a parquet shows only a handful of seam lines.
|
|
78
|
+
seamMaxHoughLines: 60,
|
|
79
|
+
// Clutter-adaptive Canny floor: on a near-empty scene (edgeDensity below
|
|
80
|
+
// lowClutterEdgeDensity %) drop the high-threshold floor to
|
|
81
|
+
// cannyHighMinLowClutter so a faint border (pale ID on pale wood) is still
|
|
82
|
+
// traced; busy scenes keep the fixed 60 floor so the high-contrast path holds.
|
|
83
|
+
lowClutterEdgeDensity: 2,
|
|
84
|
+
cannyHighMinLowClutter: 40,
|
|
85
|
+
// Gate-0 grid coverage is an early-out only: bail just on a near-empty grid
|
|
86
|
+
// (this many of 9 inner cells must carry edges). Distance / "fully visible" is
|
|
87
|
+
// owned downstream by docFillPercent >= minFillPercent (65%), so a strict bar
|
|
88
|
+
// here only false-rejected low-contrast cards on plain backgrounds before the
|
|
89
|
+
// contour pass ran. Synthetic-fallback eligibility keeps its own 7/9 signal.
|
|
90
|
+
captureGridMinCells: 4,
|
|
91
|
+
// Throttle the heavy CV pipeline to this processing rate (fps). rAF runs at the
|
|
92
|
+
// display refresh (60/90/120Hz), so a time-based throttle keeps detection — and
|
|
93
|
+
// the frame-count constants tuned against it — consistent across devices and
|
|
94
|
+
// saves ~2x CV cost on mobile. 60 effectively disables throttling.
|
|
95
|
+
targetProcessingFps: 30,
|
|
96
|
+
// Chroma-mask fallback: when no 4-corner quad forms in luminance (a strongly
|
|
97
|
+
// coloured card on a near-neutral background, e.g. a green/yellow ID on grey
|
|
98
|
+
// fabric), segment the card by chroma magnitude and accept the largest blob if
|
|
99
|
+
// it passes the same fill/aspect/wall-hug gates as a real contour. Gated to
|
|
100
|
+
// mobile (chroma fusion path) by the hook. KNOWN LIMITATION: a colourful
|
|
101
|
+
// rug/carpet patch is classically indistinguishable and can pass — toggle off
|
|
102
|
+
// here (or in the panel) if it false-captures. chromaMaskThreshold is the
|
|
103
|
+
// |a-128|+|b-128| binary cutoff; chromaMaskMinFrac/MaxFrac bound the blob's
|
|
104
|
+
// share of the ROI (a chromatic background spanning the ROI exceeds MaxFrac).
|
|
105
|
+
chromaMaskFallback: true,
|
|
106
|
+
chromaMaskThreshold: 18,
|
|
107
|
+
chromaMaskMinFrac: 0.08,
|
|
108
|
+
chromaMaskMaxFrac: 0.7,
|
|
109
|
+
cropToCard: true,
|
|
110
|
+
cropToContour: true,
|
|
111
|
+
cropPadding: 10,
|
|
112
|
+
previewCropPadding: 2,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Per-device overrides. Only the knobs that genuinely differ between a
|
|
116
|
+
// hand-held phone camera and a fixed webcam appear here, each with its reason —
|
|
117
|
+
// everything else comes from SHARED_DEFAULTS. The divergent numbers don't share
|
|
118
|
+
// a common scale factor (they were measured independently per device), so they
|
|
119
|
+
// stay as explicit, documented values rather than a synthetic multiplier.
|
|
120
|
+
const MOBILE_OVERRIDES = {
|
|
121
|
+
deviceType: 'Mobile',
|
|
122
|
+
// Show the detected card outline only on mobile (handheld framing aid).
|
|
123
|
+
useDynamicBorder: true,
|
|
124
|
+
autoCannySigma: 0.0, // mobile cameras resolve more detail, so a lower threshold is needed to detect faint edges
|
|
125
|
+
edgeDensityThreshold: 6,
|
|
126
|
+
// Phone framing is looser, so each grid cell needs only 50% of the threshold.
|
|
127
|
+
gridCellRatio: 0.5,
|
|
128
|
+
// Fix 2: OR chroma (Lab a/b) edges into the contour edge map so a card whose
|
|
129
|
+
// border is invisible in luminance (beige ID on light wood) is still detected.
|
|
130
|
+
// Mobile only — desktop has a working high-contrast path and stays off.
|
|
131
|
+
chromaEdgeFusion: true,
|
|
132
|
+
// Level 2: reject near-monochrome winners (white keyboard, blank paper) by
|
|
133
|
+
// mean chroma over the detected rect. Needs chroma fusion, so mobile only.
|
|
134
|
+
// 13 sits in the measured gap between a white keyboard (~10) and a real
|
|
135
|
+
// colour ID (~17-26).
|
|
136
|
+
chromaContentGate: true,
|
|
137
|
+
// Phone cameras resolve more detail, so demand a higher sharpness floor.
|
|
138
|
+
blurThreshold: 150,
|
|
139
|
+
// Phone flash/specular highlights are harsh and localized — strict glare cap.
|
|
140
|
+
glareThreshold: 5.0,
|
|
141
|
+
// Handheld motion → require more stable frames before auto-capture.
|
|
142
|
+
stabilityThreshold: 5,
|
|
143
|
+
minFillPercent: 65,
|
|
144
|
+
maxFillPercent: 95,
|
|
145
|
+
// Anti-flicker (mobile only — handheld jitter is the problem; webcams are
|
|
146
|
+
// steady so desktop keeps today's exact behavior via the hook's ?? fallbacks).
|
|
147
|
+
// gateDecayEnabled: on a transient gate failure, decay the stability count by
|
|
148
|
+
// 1 (within the blur/glare miss tolerance) instead of zeroing it, so a single
|
|
149
|
+
// jittery frame doesn't drain the progress ring or flip "Hold Still"↔"Align".
|
|
150
|
+
gateDecayEnabled: true,
|
|
151
|
+
// docFillEmaAlpha: EMA smoothing of the distance fill % (1 = off). At the
|
|
152
|
+
// default 30fps throttle the EMA updates ~half as often as the old 60fps loop,
|
|
153
|
+
// so 0.45 keeps a similar wall-clock time constant (~3 processed frames)
|
|
154
|
+
// without over-lagging a real move.
|
|
155
|
+
docFillEmaAlpha: 0.45,
|
|
156
|
+
// fillHysteresis: deadband (pct points) around min/maxFillPercent so a hand
|
|
157
|
+
// hovering on the 65%/95% boundary doesn't toggle the distance gate.
|
|
158
|
+
fillHysteresis: 3,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const DESKTOP_OVERRIDES = {
|
|
162
|
+
deviceType: 'Desktop',
|
|
163
|
+
useDynamicBorder: false,
|
|
164
|
+
edgeDensityThreshold: 10,
|
|
165
|
+
// Webcam ROI is the visible box the user fills — stricter per-cell coverage.
|
|
166
|
+
gridCellRatio: 0.6,
|
|
167
|
+
chromaEdgeFusion: false,
|
|
168
|
+
chromaContentGate: false,
|
|
169
|
+
// Fixed-focus webcams are softer; a 150 floor would never pass, so use 60.
|
|
170
|
+
blurThreshold: 60,
|
|
171
|
+
// Webcams sit under diffuse room light — much more glare is normal/acceptable.
|
|
172
|
+
glareThreshold: 18.0,
|
|
173
|
+
// Webcam on a stand is steady, so fewer stable frames are needed.
|
|
174
|
+
stabilityThreshold: 3,
|
|
175
|
+
// Desktop ROI == the visible video box (see useCardDetection's skipGridCheck
|
|
176
|
+
// branch), so these percentages are measured against what the user actually
|
|
177
|
+
// sees. Require ~70% area fill (~84% linear — still ~990px of card width on a
|
|
178
|
+
// 720p webcam) before quality checks; allow up to 98% before backing off. The
|
|
179
|
+
// lower floor lets fixed-focus webcams sit at a sharper distance.
|
|
180
|
+
minFillPercent: 70,
|
|
181
|
+
maxFillPercent: 98,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const getOptimalDefaults = () => {
|
|
185
|
+
const ua =
|
|
186
|
+
navigator.userAgent ||
|
|
187
|
+
navigator.vendor ||
|
|
188
|
+
(window as unknown as { opera?: string }).opera ||
|
|
189
|
+
'';
|
|
190
|
+
const isMobile =
|
|
191
|
+
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
...SHARED_DEFAULTS,
|
|
195
|
+
...(isMobile ? MOBILE_OVERRIDES : DESKTOP_OVERRIDES),
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const galleryButtonStyle = {
|
|
200
|
+
width: 72,
|
|
201
|
+
height: 72,
|
|
202
|
+
borderRadius: '50%',
|
|
203
|
+
border: 'none',
|
|
204
|
+
backgroundColor: 'transparent',
|
|
205
|
+
color: '#fff',
|
|
206
|
+
display: 'flex',
|
|
207
|
+
alignItems: 'center',
|
|
208
|
+
justifyContent: 'center',
|
|
209
|
+
cursor: 'pointer',
|
|
210
|
+
boxShadow: 'none',
|
|
211
|
+
padding: 0,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const FEEDBACK_MIN_DISPLAY_MS = 500;
|
|
215
|
+
|
|
216
|
+
function GalleryIcon() {
|
|
217
|
+
return (
|
|
218
|
+
<svg
|
|
219
|
+
width="56"
|
|
220
|
+
height="56"
|
|
221
|
+
viewBox="0 0 56 56"
|
|
222
|
+
fill="none"
|
|
223
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
224
|
+
aria-hidden="true"
|
|
225
|
+
>
|
|
226
|
+
<mask id="gallery-btn-inside" fill="white">
|
|
227
|
+
<path d="M0 28C0 12.536 12.536 0 28 0C43.464 0 56 12.536 56 28C56 43.464 43.464 56 28 56C12.536 56 0 43.464 0 28Z" />
|
|
228
|
+
</mask>
|
|
229
|
+
<path
|
|
230
|
+
d="M0 28C0 12.536 12.536 0 28 0C43.464 0 56 12.536 56 28C56 43.464 43.464 56 28 56C12.536 56 0 43.464 0 28Z"
|
|
231
|
+
fill="#151F72"
|
|
232
|
+
/>
|
|
233
|
+
<path
|
|
234
|
+
d="M0 28M56 28M56 28M0 28M28 0M56 28M28 56M0 28M28 56V55C13.0883 55 1 42.9117 1 28H0H-1C-1 44.0163 11.9837 57 28 57V56ZM56 28H55C55 42.9117 42.9117 55 28 55V56V57C44.0163 57 57 44.0163 57 28H56ZM28 0V1C42.9117 1 55 13.0883 55 28H56H57C57 11.9837 44.0163 -1 28 -1V0ZM28 0V-1C11.9837 -1 -1 11.9837 -1 28H0H1C1 13.0883 13.0883 1 28 1V0Z"
|
|
235
|
+
fill="white"
|
|
236
|
+
fillOpacity="0.1"
|
|
237
|
+
mask="url(#gallery-btn-inside)"
|
|
238
|
+
/>
|
|
239
|
+
<path
|
|
240
|
+
d="M35 19H21C19.8954 19 19 19.8954 19 21V35C19 36.1046 19.8954 37 21 37H35C36.1046 37 37 36.1046 37 35V21C37 19.8954 36.1046 19 35 19Z"
|
|
241
|
+
stroke="white"
|
|
242
|
+
strokeWidth="2"
|
|
243
|
+
strokeLinecap="round"
|
|
244
|
+
strokeLinejoin="round"
|
|
245
|
+
/>
|
|
246
|
+
<path
|
|
247
|
+
d="M25 27C26.1046 27 27 26.1046 27 25C27 23.8954 26.1046 23 25 23C23.8954 23 23 23.8954 23 25C23 26.1046 23.8954 27 25 27Z"
|
|
248
|
+
stroke="white"
|
|
249
|
+
strokeWidth="2"
|
|
250
|
+
strokeLinecap="round"
|
|
251
|
+
strokeLinejoin="round"
|
|
252
|
+
/>
|
|
253
|
+
<path
|
|
254
|
+
d="M37 30.9999L33.914 27.9139C33.5389 27.539 33.0303 27.3284 32.5 27.3284C31.9697 27.3284 31.4611 27.539 31.086 27.9139L22 36.9999"
|
|
255
|
+
stroke="white"
|
|
256
|
+
strokeWidth="2"
|
|
257
|
+
strokeLinecap="round"
|
|
258
|
+
strokeLinejoin="round"
|
|
259
|
+
/>
|
|
260
|
+
</svg>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function GalleryButton({ onClick }: { onClick: () => void }) {
|
|
265
|
+
return (
|
|
266
|
+
<button
|
|
267
|
+
onClick={onClick}
|
|
268
|
+
style={galleryButtonStyle}
|
|
269
|
+
aria-label={translate('document.autoCapture.galleryButtonLabel')}
|
|
270
|
+
>
|
|
271
|
+
<GalleryIcon />
|
|
272
|
+
</button>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Inner implementation that owns the camera + detection loop. Only mounted
|
|
278
|
+
* when the host element is actually visible (see `DocumentAutoCapture`
|
|
279
|
+
* wrapper below) so that two sibling instances — front + back — don't both
|
|
280
|
+
* fight for `getUserMedia` and run rAF/OpenCV detection while one is
|
|
281
|
+
* `hidden`. That collision was causing the page to freeze when the element
|
|
282
|
+
* was used inside `<document-capture-screens>`.
|
|
283
|
+
*/
|
|
284
|
+
function DesktopCaptureButton({
|
|
285
|
+
progress = 0,
|
|
286
|
+
themeColor = '#001096',
|
|
287
|
+
disabled = false,
|
|
288
|
+
onClick,
|
|
289
|
+
}: {
|
|
290
|
+
progress: number;
|
|
291
|
+
themeColor: string;
|
|
292
|
+
disabled: boolean;
|
|
293
|
+
onClick: () => void;
|
|
294
|
+
}) {
|
|
295
|
+
const size = 70;
|
|
296
|
+
const strokeWidth = 4;
|
|
297
|
+
const ringRadius = size / 2 - strokeWidth / 2;
|
|
298
|
+
const circumference = 2 * Math.PI * ringRadius;
|
|
299
|
+
const offset = circumference - (progress / 100) * circumference;
|
|
300
|
+
const isActive = progress > 0 && progress < 100;
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<button
|
|
304
|
+
onClick={onClick}
|
|
305
|
+
disabled={disabled}
|
|
306
|
+
style={{
|
|
307
|
+
position: 'relative',
|
|
308
|
+
width: size,
|
|
309
|
+
height: size,
|
|
310
|
+
borderRadius: '50%',
|
|
311
|
+
border: 'none',
|
|
312
|
+
background: 'transparent',
|
|
313
|
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
314
|
+
padding: 0,
|
|
315
|
+
opacity: disabled ? 0.4 : 1,
|
|
316
|
+
transition: 'opacity 0.2s ease',
|
|
317
|
+
WebkitTapHighlightColor: 'transparent',
|
|
318
|
+
flexShrink: 0,
|
|
319
|
+
}}
|
|
320
|
+
aria-label={translate('document.autoCapture.capturePhotoButton')}
|
|
321
|
+
>
|
|
322
|
+
<svg
|
|
323
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
324
|
+
width={size}
|
|
325
|
+
height={size}
|
|
326
|
+
viewBox="0 0 70 70"
|
|
327
|
+
fill="none"
|
|
328
|
+
aria-hidden="true"
|
|
329
|
+
style={{ display: 'block' }}
|
|
330
|
+
>
|
|
331
|
+
<path
|
|
332
|
+
fillRule="evenodd"
|
|
333
|
+
clipRule="evenodd"
|
|
334
|
+
d="M35 70C54.33 70 70 54.33 70 35C70 15.67 54.33 0 35 0C15.67 0 0 15.67 0 35C0 54.33 15.67 70 35 70ZM61 35C61 49.3594 49.3594 61 35 61C20.6406 61 9 49.3594 9 35C9 20.6406 20.6406 9 35 9C49.3594 9 61 20.6406 61 35ZM65 35C65 51.5685 51.5685 65 35 65C18.4315 65 5 51.5685 5 35C5 18.4315 18.4315 5 35 5C51.5685 5 65 18.4315 65 35Z"
|
|
335
|
+
fill={themeColor}
|
|
336
|
+
/>
|
|
337
|
+
</svg>
|
|
338
|
+
{isActive && (
|
|
339
|
+
<svg
|
|
340
|
+
width={size}
|
|
341
|
+
height={size}
|
|
342
|
+
style={{
|
|
343
|
+
position: 'absolute',
|
|
344
|
+
top: 0,
|
|
345
|
+
left: 0,
|
|
346
|
+
pointerEvents: 'none',
|
|
347
|
+
}}
|
|
348
|
+
aria-hidden="true"
|
|
349
|
+
>
|
|
350
|
+
<circle
|
|
351
|
+
cx={size / 2}
|
|
352
|
+
cy={size / 2}
|
|
353
|
+
r={ringRadius}
|
|
354
|
+
fill="none"
|
|
355
|
+
stroke="#2CC05C"
|
|
356
|
+
strokeWidth={strokeWidth}
|
|
357
|
+
strokeDasharray={circumference}
|
|
358
|
+
strokeDashoffset={offset}
|
|
359
|
+
strokeLinecap="round"
|
|
360
|
+
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
|
361
|
+
style={{ transition: 'stroke-dashoffset 0.3s ease' }}
|
|
362
|
+
/>
|
|
363
|
+
</svg>
|
|
364
|
+
)}
|
|
365
|
+
</button>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
const AUTO_CAPTURE_TIMEOUT_MIN_MS = 3_000;
|
|
369
|
+
const AUTO_CAPTURE_TIMEOUT_MAX_MS = 30_000;
|
|
370
|
+
const AUTO_CAPTURE_TIMEOUT_DEFAULT_MS = 20_000;
|
|
371
|
+
const DocumentAutoCaptureInner: FunctionComponent<Props> = ({
|
|
372
|
+
'document-type': documentTypeProp = '',
|
|
373
|
+
'auto-capture': captureModeProp = 'autoCapture',
|
|
374
|
+
'auto-capture-timeout':
|
|
375
|
+
autoCaptureTimeoutProp = AUTO_CAPTURE_TIMEOUT_DEFAULT_MS,
|
|
376
|
+
'side-of-id': sideOfId = 'Front',
|
|
377
|
+
'theme-color': themeColor = '#001096',
|
|
378
|
+
title = '',
|
|
379
|
+
'show-navigation': showNavigationProp = false,
|
|
380
|
+
'allow-gallery-upload': allowGalleryUploadProp = true,
|
|
381
|
+
'document-capture-modes': documentCaptureModesProp,
|
|
382
|
+
'sync-roi-to-guide': syncRoiToGuideProp = true,
|
|
383
|
+
}) => {
|
|
384
|
+
const galleryInputRef = useRef<HTMLInputElement | null>(null);
|
|
385
|
+
const captureFiredRef = useRef(false);
|
|
386
|
+
|
|
387
|
+
const showNavigation = getBoolProp(showNavigationProp);
|
|
388
|
+
// Honour `document-capture-modes`: when explicitly set to a value that
|
|
389
|
+
// does not include `upload` (e.g. just `camera`), the gallery upload
|
|
390
|
+
// affordance must be hidden regardless of the `allow-gallery-upload`
|
|
391
|
+
// default. This mirrors how <document-capture-instructions> gates its
|
|
392
|
+
// upload button on the same attribute.
|
|
393
|
+
const captureModesAllowUpload = (() => {
|
|
394
|
+
if (
|
|
395
|
+
documentCaptureModesProp === undefined ||
|
|
396
|
+
documentCaptureModesProp === null
|
|
397
|
+
) {
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
const modes = String(documentCaptureModesProp)
|
|
401
|
+
.toLowerCase()
|
|
402
|
+
.split(',')
|
|
403
|
+
.map((m) => m.trim())
|
|
404
|
+
.filter(Boolean);
|
|
405
|
+
if (modes.length === 0) return true;
|
|
406
|
+
return modes.includes('upload');
|
|
407
|
+
})();
|
|
408
|
+
const allowGalleryUpload =
|
|
409
|
+
getBoolProp(allowGalleryUploadProp, true) && captureModesAllowUpload;
|
|
410
|
+
const syncRoiToGuide = getBoolProp(syncRoiToGuideProp, true);
|
|
411
|
+
|
|
412
|
+
const captureMode: CaptureMode = CAPTURE_MODES.includes(
|
|
413
|
+
captureModeProp as CaptureMode,
|
|
414
|
+
)
|
|
415
|
+
? (captureModeProp as CaptureMode)
|
|
416
|
+
: 'autoCapture';
|
|
417
|
+
// Clamp to the documented 3000–30000ms range. Values outside this band
|
|
418
|
+
// tend to either fire the manual fallback before the user has a chance
|
|
419
|
+
// to align the document (too low) or never surface it at all (too high).
|
|
420
|
+
const autoCaptureTimeout = (() => {
|
|
421
|
+
const n = Number(autoCaptureTimeoutProp);
|
|
422
|
+
if (!Number.isFinite(n) || n <= 0) return AUTO_CAPTURE_TIMEOUT_DEFAULT_MS;
|
|
423
|
+
return Math.min(
|
|
424
|
+
AUTO_CAPTURE_TIMEOUT_MAX_MS,
|
|
425
|
+
Math.max(AUTO_CAPTURE_TIMEOUT_MIN_MS, n),
|
|
426
|
+
);
|
|
427
|
+
})();
|
|
428
|
+
|
|
429
|
+
// Map upper-case enum values used elsewhere in web-components (GREEN_BOOK,
|
|
430
|
+
// ID_CARD, PASSPORT) to the lower-case keys used by useCardDetection.
|
|
431
|
+
const documentType = ((): 'greenbook' | 'id-card' | 'passport' | null => {
|
|
432
|
+
if (!documentTypeProp) return null;
|
|
433
|
+
const v = String(documentTypeProp).toLowerCase();
|
|
434
|
+
if (v === 'green_book' || v === 'greenbook') return 'greenbook';
|
|
435
|
+
if (v === 'id_card' || v === 'id-card') return 'id-card';
|
|
436
|
+
if (v === 'passport') return 'passport';
|
|
437
|
+
return 'id-card';
|
|
438
|
+
})();
|
|
439
|
+
|
|
440
|
+
const [settings, setSettings] = useState(getOptimalDefaults());
|
|
441
|
+
// Track the camera-viewport box (the absolute-positioned div that fills the
|
|
442
|
+
// host) rather than the page viewport, so the component fills its parent
|
|
443
|
+
// even when embedded inside another layout (e.g. <document-capture-screens>).
|
|
444
|
+
const cameraViewportRef = useRef<HTMLDivElement>(null);
|
|
445
|
+
// The shared <smileid-navigation> element (only one layout mounts at a time).
|
|
446
|
+
const navigationRef = useRef<HTMLElement | null>(null);
|
|
447
|
+
const [viewportBox, setViewportBox] = useState<{ w: number; h: number }>({
|
|
448
|
+
w: 0,
|
|
449
|
+
h: 0,
|
|
450
|
+
});
|
|
451
|
+
const isTallViewport = viewportBox.h > viewportBox.w;
|
|
452
|
+
const updateSetting = (key: string, value: unknown) =>
|
|
453
|
+
setSettings((prev) => ({ ...prev, [key]: value }));
|
|
454
|
+
// Debug UI (tuning panel + ROI overlay) is compiled in for dev + preview only
|
|
455
|
+
// (see utils/debug.ts / __SMILE_DEBUG__); production builds strip it.
|
|
456
|
+
const showDebug = isDebugEnabled();
|
|
457
|
+
|
|
458
|
+
// Lazy-load OpenCV on mount; the detection hook polls for `cv.Mat`.
|
|
459
|
+
useEffect(() => {
|
|
460
|
+
ensureOpenCv().catch((err: unknown) => {
|
|
461
|
+
console.warn('[document-auto-capture] OpenCV load failed:', err);
|
|
462
|
+
});
|
|
463
|
+
}, []);
|
|
464
|
+
|
|
465
|
+
// Observe the camera viewport's box for orientation + rotation sizing.
|
|
466
|
+
useEffect(() => {
|
|
467
|
+
const el = cameraViewportRef.current;
|
|
468
|
+
if (!el || typeof ResizeObserver === 'undefined') return undefined;
|
|
469
|
+
|
|
470
|
+
const update = () => {
|
|
471
|
+
setViewportBox({ w: el.clientWidth, h: el.clientHeight });
|
|
472
|
+
};
|
|
473
|
+
update();
|
|
474
|
+
|
|
475
|
+
const ro = new ResizeObserver(update);
|
|
476
|
+
ro.observe(el);
|
|
477
|
+
return () => ro.disconnect();
|
|
478
|
+
}, []);
|
|
479
|
+
|
|
480
|
+
const isLandscapeDocumentType =
|
|
481
|
+
documentType === 'id-card' || documentType === 'passport';
|
|
482
|
+
const useLandscapeUi = isLandscapeDocumentType;
|
|
483
|
+
// Rotate the UI 90 degrees only on touch devices (mobile / tablet) where
|
|
484
|
+
// the user is expected to be holding the phone in portrait. On desktop /
|
|
485
|
+
// laptop with a portrait-shaped parent box (e.g. the dev playground
|
|
486
|
+
// 360px column) we keep the un-rotated layout because the user can't
|
|
487
|
+
// rotate their monitor.
|
|
488
|
+
const isMobileDevice = settings.deviceType === 'Mobile';
|
|
489
|
+
const shouldRotateUi = useLandscapeUi && isTallViewport && isMobileDevice;
|
|
490
|
+
const effectiveCaptureOrientation = isLandscapeDocumentType
|
|
491
|
+
? 'landscape'
|
|
492
|
+
: 'portrait';
|
|
493
|
+
|
|
494
|
+
const { videoRef, error } = useCamera();
|
|
495
|
+
const {
|
|
496
|
+
feedback,
|
|
497
|
+
captureProgress,
|
|
498
|
+
capturedImage,
|
|
499
|
+
previewImage,
|
|
500
|
+
captureOrigin,
|
|
501
|
+
complianceState,
|
|
502
|
+
debugInfo,
|
|
503
|
+
debugPath,
|
|
504
|
+
debugRoi,
|
|
505
|
+
detectedDocType,
|
|
506
|
+
guideAspectRatio,
|
|
507
|
+
manualFallbackActive,
|
|
508
|
+
cvLoadFailed,
|
|
509
|
+
triggerManualCapture,
|
|
510
|
+
} = useCardDetection(videoRef, settings, {
|
|
511
|
+
variant: 'fullscreen',
|
|
512
|
+
documentType,
|
|
513
|
+
captureMode,
|
|
514
|
+
autoCaptureTimeout,
|
|
515
|
+
captureOrientation: effectiveCaptureOrientation,
|
|
516
|
+
shouldRotateUi,
|
|
517
|
+
syncRoiToGuide,
|
|
518
|
+
skipGridCheck: settings.deviceType !== 'Mobile',
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const [visibleFeedback, setVisibleFeedback] = useState<string>(feedback);
|
|
522
|
+
const feedbackHoldUntilRef = useRef(0);
|
|
523
|
+
const pendingFeedbackRef = useRef<string | null>(null);
|
|
524
|
+
const feedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
525
|
+
|
|
526
|
+
useEffect(() => {
|
|
527
|
+
const immediateState =
|
|
528
|
+
complianceState === COMPLIANCE_STATES.CAPTURING ||
|
|
529
|
+
complianceState === COMPLIANCE_STATES.SUCCESS;
|
|
530
|
+
|
|
531
|
+
if (immediateState || !visibleFeedback) {
|
|
532
|
+
if (feedbackTimerRef.current) {
|
|
533
|
+
clearTimeout(feedbackTimerRef.current);
|
|
534
|
+
feedbackTimerRef.current = null;
|
|
535
|
+
}
|
|
536
|
+
pendingFeedbackRef.current = null;
|
|
537
|
+
setVisibleFeedback(feedback);
|
|
538
|
+
feedbackHoldUntilRef.current = Date.now() + FEEDBACK_MIN_DISPLAY_MS;
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (feedback === visibleFeedback) {
|
|
543
|
+
if (feedbackTimerRef.current) {
|
|
544
|
+
clearTimeout(feedbackTimerRef.current);
|
|
545
|
+
feedbackTimerRef.current = null;
|
|
546
|
+
}
|
|
547
|
+
pendingFeedbackRef.current = null;
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const now = Date.now();
|
|
552
|
+
const remaining = feedbackHoldUntilRef.current - now;
|
|
553
|
+
|
|
554
|
+
if (remaining <= 0) {
|
|
555
|
+
setVisibleFeedback(feedback);
|
|
556
|
+
feedbackHoldUntilRef.current = now + FEEDBACK_MIN_DISPLAY_MS;
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
pendingFeedbackRef.current = feedback;
|
|
561
|
+
if (feedbackTimerRef.current) return;
|
|
562
|
+
|
|
563
|
+
feedbackTimerRef.current = setTimeout(() => {
|
|
564
|
+
feedbackTimerRef.current = null;
|
|
565
|
+
if (pendingFeedbackRef.current) {
|
|
566
|
+
setVisibleFeedback(pendingFeedbackRef.current);
|
|
567
|
+
pendingFeedbackRef.current = null;
|
|
568
|
+
feedbackHoldUntilRef.current = Date.now() + FEEDBACK_MIN_DISPLAY_MS;
|
|
569
|
+
}
|
|
570
|
+
}, remaining);
|
|
571
|
+
}, [feedback, complianceState, visibleFeedback]);
|
|
572
|
+
|
|
573
|
+
useEffect(
|
|
574
|
+
() => () => {
|
|
575
|
+
if (feedbackTimerRef.current) {
|
|
576
|
+
clearTimeout(feedbackTimerRef.current);
|
|
577
|
+
feedbackTimerRef.current = null;
|
|
578
|
+
}
|
|
579
|
+
},
|
|
580
|
+
[],
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
// Debounced compliance state for visual output.
|
|
584
|
+
// The raw complianceState updates every detection frame. Feeding it directly
|
|
585
|
+
// to the Overlay/spinner causes rapid color oscillation when quality is
|
|
586
|
+
// borderline. Debounce is ASYMMETRIC: snap immediately INTO the "good" green
|
|
587
|
+
// states (STABLE/CAPTURING/SUCCESS) so the guide turns green the instant the
|
|
588
|
+
// Hold-Still phase begins — that phase is only ~5 frames, shorter than the
|
|
589
|
+
// debounce, so a trailing debounce would skip green entirely — but apply the
|
|
590
|
+
// 150ms trailing debounce when DOWNGRADING to DETECTING/IDLE so a transient
|
|
591
|
+
// miss doesn't flash the border back to amber.
|
|
592
|
+
const COMPLIANCE_DEBOUNCE_MS = 150;
|
|
593
|
+
const [visibleComplianceState, setVisibleComplianceState] =
|
|
594
|
+
useState(complianceState);
|
|
595
|
+
useEffect(() => {
|
|
596
|
+
const isGoodState =
|
|
597
|
+
complianceState === COMPLIANCE_STATES.STABLE ||
|
|
598
|
+
complianceState === COMPLIANCE_STATES.CAPTURING ||
|
|
599
|
+
complianceState === COMPLIANCE_STATES.SUCCESS;
|
|
600
|
+
const timer = setTimeout(
|
|
601
|
+
() => setVisibleComplianceState(complianceState),
|
|
602
|
+
isGoodState ? 0 : COMPLIANCE_DEBOUNCE_MS,
|
|
603
|
+
);
|
|
604
|
+
return () => clearTimeout(timer);
|
|
605
|
+
}, [complianceState]);
|
|
606
|
+
|
|
607
|
+
// Notify smart-camera-web when the capture session begins.
|
|
608
|
+
useEffect(() => {
|
|
609
|
+
const smartCameraWeb = document.querySelector('smart-camera-web');
|
|
610
|
+
smartCameraWeb?.dispatchEvent(
|
|
611
|
+
new CustomEvent('metadata.document-capture-start', {
|
|
612
|
+
detail: { side: sideOfId },
|
|
613
|
+
}),
|
|
614
|
+
);
|
|
615
|
+
return () => {
|
|
616
|
+
smartCameraWeb?.dispatchEvent(
|
|
617
|
+
new CustomEvent('metadata.document-capture-end', {
|
|
618
|
+
detail: { side: sideOfId },
|
|
619
|
+
}),
|
|
620
|
+
);
|
|
621
|
+
};
|
|
622
|
+
}, [sideOfId]);
|
|
623
|
+
|
|
624
|
+
// Resolve THIS instance's host element (not the first match in the
|
|
625
|
+
// document) by walking up from a node inside the shadow root. Using
|
|
626
|
+
// `document.querySelector('document-auto-capture')` here is wrong when
|
|
627
|
+
// there are sibling instances (front + back), because it always returns
|
|
628
|
+
// the front and the back's events would never reach its listener.
|
|
629
|
+
const getHost = (): Element | null => {
|
|
630
|
+
const node =
|
|
631
|
+
cameraViewportRef.current || videoRef.current || galleryInputRef.current;
|
|
632
|
+
const root = node?.getRootNode();
|
|
633
|
+
if (root && root instanceof ShadowRoot) return root.host;
|
|
634
|
+
return document.querySelector('document-auto-capture');
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
// Re-encode at JPEG_QUALITY before publishing so output matches the legacy
|
|
638
|
+
// `<document-capture>` element exactly. The detection hook captures at 0.95
|
|
639
|
+
// quality for internal use; we round-trip via an Image to set the package's
|
|
640
|
+
// canonical JPEG_QUALITY.
|
|
641
|
+
const reencodeJpeg = (
|
|
642
|
+
dataUrl: string,
|
|
643
|
+
): Promise<{ data: string; width: number; height: number }> =>
|
|
644
|
+
new Promise((resolve, reject) => {
|
|
645
|
+
const img = new Image();
|
|
646
|
+
img.onload = () => {
|
|
647
|
+
try {
|
|
648
|
+
const canvas = document.createElement('canvas');
|
|
649
|
+
canvas.width = img.width;
|
|
650
|
+
canvas.height = img.height;
|
|
651
|
+
const ctx = canvas.getContext('2d');
|
|
652
|
+
if (!ctx) {
|
|
653
|
+
reject(new Error('2d context unavailable'));
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
ctx.drawImage(img, 0, 0);
|
|
657
|
+
resolve({
|
|
658
|
+
data: canvas.toDataURL('image/jpeg', JPEG_QUALITY),
|
|
659
|
+
width: canvas.width,
|
|
660
|
+
height: canvas.height,
|
|
661
|
+
});
|
|
662
|
+
} catch (err) {
|
|
663
|
+
reject(err);
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
img.onerror = reject;
|
|
667
|
+
img.src = dataUrl;
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
const publishImage = (
|
|
671
|
+
dataUrl: string,
|
|
672
|
+
origin: string,
|
|
673
|
+
preview: string | null,
|
|
674
|
+
) => {
|
|
675
|
+
if (!dataUrl || captureFiredRef.current) return;
|
|
676
|
+
captureFiredRef.current = true;
|
|
677
|
+
|
|
678
|
+
const fullPromise = reencodeJpeg(dataUrl);
|
|
679
|
+
const previewPromise = preview
|
|
680
|
+
? reencodeJpeg(preview)
|
|
681
|
+
: Promise.resolve(null);
|
|
682
|
+
|
|
683
|
+
Promise.all([fullPromise, previewPromise])
|
|
684
|
+
.then(([full, prev]) => {
|
|
685
|
+
const finalImage = full.data;
|
|
686
|
+
const previewOut = prev ? prev.data : finalImage;
|
|
687
|
+
|
|
688
|
+
// Use the same event surface as the legacy element for drop-in parity.
|
|
689
|
+
const host = getHost();
|
|
690
|
+
const target = host || document;
|
|
691
|
+
target.dispatchEvent(
|
|
692
|
+
new CustomEvent('document-capture.publish', {
|
|
693
|
+
bubbles: true,
|
|
694
|
+
composed: true,
|
|
695
|
+
detail: {
|
|
696
|
+
image: finalImage,
|
|
697
|
+
originalHeight: full.height,
|
|
698
|
+
originalWidth: full.width,
|
|
699
|
+
previewImage: previewOut,
|
|
700
|
+
side: sideOfId,
|
|
701
|
+
captureOrigin: origin,
|
|
702
|
+
},
|
|
703
|
+
}),
|
|
704
|
+
);
|
|
705
|
+
})
|
|
706
|
+
.catch(() => {
|
|
707
|
+
console.error('[document-auto-capture] failed to decode capture');
|
|
708
|
+
});
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
useEffect(() => {
|
|
712
|
+
if (capturedImage) {
|
|
713
|
+
publishImage(
|
|
714
|
+
capturedImage,
|
|
715
|
+
captureOrigin || 'camera_auto_capture',
|
|
716
|
+
previewImage,
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
}, [capturedImage]);
|
|
720
|
+
|
|
721
|
+
// Wire up navigation back/close events.
|
|
722
|
+
const dispatchHostEvent = (name: string) => {
|
|
723
|
+
const host = getHost();
|
|
724
|
+
(host || document).dispatchEvent(
|
|
725
|
+
new CustomEvent(name, { bubbles: true, composed: true }),
|
|
726
|
+
);
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
// `document-capture.*` is the canonical event name: it matches the legacy
|
|
730
|
+
// <document-capture> element (so this stays a drop-in replacement), the
|
|
731
|
+
// README, and the listener DocumentCaptureScreens binds for "cancelled".
|
|
732
|
+
// The `document-auto-capture.*` variant is also emitted so the screens
|
|
733
|
+
// wrapper's dynamic `${nodeName}.close` listener still fires.
|
|
734
|
+
const onBack = () => {
|
|
735
|
+
dispatchHostEvent('document-capture.cancelled');
|
|
736
|
+
dispatchHostEvent('document-auto-capture.cancelled');
|
|
737
|
+
};
|
|
738
|
+
const onClose = () => {
|
|
739
|
+
dispatchHostEvent('document-capture.close');
|
|
740
|
+
dispatchHostEvent('document-auto-capture.close');
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
// Bridge the <smileid-navigation> element's custom events to the same
|
|
744
|
+
// back/close handlers. Mirrors SmartSelfieCapture's wiring; only one layout
|
|
745
|
+
// (and thus one navigation element) is mounted at a time, so a single ref
|
|
746
|
+
// suffices.
|
|
747
|
+
useEffect(() => {
|
|
748
|
+
const navigation = navigationRef.current;
|
|
749
|
+
if (!navigation || !showNavigation) return undefined;
|
|
750
|
+
const handleBack = () => onBack();
|
|
751
|
+
const handleClose = () => onClose();
|
|
752
|
+
navigation.addEventListener('navigation.back', handleBack);
|
|
753
|
+
navigation.addEventListener('navigation.close', handleClose);
|
|
754
|
+
return () => {
|
|
755
|
+
navigation.removeEventListener('navigation.back', handleBack);
|
|
756
|
+
navigation.removeEventListener('navigation.close', handleClose);
|
|
757
|
+
};
|
|
758
|
+
}, [showNavigation]);
|
|
759
|
+
|
|
760
|
+
// Capture-button ring progress. `captureProgress` (0–100) already reflects
|
|
761
|
+
// the stability count vs the threshold; the previous `debugInfo.stability`
|
|
762
|
+
// lookup was always undefined (the hook never sets that field), so the ring
|
|
763
|
+
// stayed at 0.
|
|
764
|
+
const progress =
|
|
765
|
+
visibleComplianceState === COMPLIANCE_STATES.STABLE ? captureProgress : 0;
|
|
766
|
+
|
|
767
|
+
// Whether to show the manual capture button.
|
|
768
|
+
// manualCaptureOnly — always show
|
|
769
|
+
// autoCapture — show after timeout fallback or if OpenCV failed to load
|
|
770
|
+
// autoCaptureOnly — never show (error UI handles cv load failure)
|
|
771
|
+
const showManualButton =
|
|
772
|
+
captureMode === 'manualCaptureOnly' ||
|
|
773
|
+
(captureMode === 'autoCapture' && (manualFallbackActive || cvLoadFailed));
|
|
774
|
+
|
|
775
|
+
if (error) {
|
|
776
|
+
return (
|
|
777
|
+
<div
|
|
778
|
+
style={{
|
|
779
|
+
flex: 1,
|
|
780
|
+
display: 'flex',
|
|
781
|
+
flexDirection: 'column',
|
|
782
|
+
alignItems: 'center',
|
|
783
|
+
justifyContent: 'center',
|
|
784
|
+
padding: theme.spacing.xl,
|
|
785
|
+
textAlign: 'center',
|
|
786
|
+
color: theme.colors.error,
|
|
787
|
+
}}
|
|
788
|
+
>
|
|
789
|
+
<svg
|
|
790
|
+
width="48"
|
|
791
|
+
height="48"
|
|
792
|
+
viewBox="0 0 24 24"
|
|
793
|
+
fill="none"
|
|
794
|
+
stroke="currentColor"
|
|
795
|
+
strokeWidth="2"
|
|
796
|
+
>
|
|
797
|
+
<circle cx="12" cy="12" r="10" />
|
|
798
|
+
<line x1="15" y1="9" x2="9" y2="15" />
|
|
799
|
+
<line x1="9" y1="9" x2="15" y2="15" />
|
|
800
|
+
</svg>
|
|
801
|
+
<p style={{ marginTop: theme.spacing.md, fontSize: '1rem' }}>
|
|
802
|
+
{translate('document.autoCapture.error.cameraUnavailable.title')}
|
|
803
|
+
</p>
|
|
804
|
+
<p
|
|
805
|
+
style={{
|
|
806
|
+
fontSize: '0.85rem',
|
|
807
|
+
color: theme.colors.textSecondary,
|
|
808
|
+
marginTop: theme.spacing.sm,
|
|
809
|
+
}}
|
|
810
|
+
>
|
|
811
|
+
{translate('document.autoCapture.error.cameraUnavailable.body')}
|
|
812
|
+
</p>
|
|
813
|
+
</div>
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/* ---- Fullscreen layout ---- */
|
|
818
|
+
const baseShowSideSpinner = [
|
|
819
|
+
COMPLIANCE_STATES.DETECTING,
|
|
820
|
+
COMPLIANCE_STATES.STABLE,
|
|
821
|
+
COMPLIANCE_STATES.CAPTURING,
|
|
822
|
+
].includes(visibleComplianceState);
|
|
823
|
+
|
|
824
|
+
let spinnerProgress: number;
|
|
825
|
+
if (visibleComplianceState === COMPLIANCE_STATES.STABLE) {
|
|
826
|
+
spinnerProgress = Math.max(15, progress);
|
|
827
|
+
} else if (visibleComplianceState === COMPLIANCE_STATES.CAPTURING) {
|
|
828
|
+
spinnerProgress = 99;
|
|
829
|
+
} else {
|
|
830
|
+
spinnerProgress = 25;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const showManualCaptureControl =
|
|
834
|
+
showManualButton ||
|
|
835
|
+
(allowGalleryUpload && captureMode !== 'autoCaptureOnly');
|
|
836
|
+
|
|
837
|
+
// Side-mounted controls are only used in the rotated UI. When the UI is
|
|
838
|
+
// not rotated (e.g. landscape doc type on desktop), buttons live in the
|
|
839
|
+
// bottom row alongside the portrait layout.
|
|
840
|
+
const useSideManualCapture = shouldRotateUi && showManualCaptureControl;
|
|
841
|
+
const showSideGalleryButton = shouldRotateUi && allowGalleryUpload;
|
|
842
|
+
const showBottomGalleryButton = allowGalleryUpload && !showSideGalleryButton;
|
|
843
|
+
// Only show the side progress spinner when the UI is actually rotated
|
|
844
|
+
// (landscape doc type on a portrait viewport). In portrait/un-rotated UI
|
|
845
|
+
// the bottom CaptureButton already shows progress, so a second spinner on
|
|
846
|
+
// the right would just be a duplicate floating button.
|
|
847
|
+
const showSideSpinner =
|
|
848
|
+
baseShowSideSpinner && shouldRotateUi && !useSideManualCapture;
|
|
849
|
+
const sideButtonProgress =
|
|
850
|
+
visibleComplianceState === COMPLIANCE_STATES.STABLE ? captureProgress : 0;
|
|
851
|
+
|
|
852
|
+
const handlePickFromGallery = () => {
|
|
853
|
+
if (!allowGalleryUpload) return;
|
|
854
|
+
galleryInputRef.current?.click();
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
const handleGalleryChange = (event: Event) => {
|
|
858
|
+
const target = event.target as HTMLInputElement | null;
|
|
859
|
+
const file = target?.files?.[0];
|
|
860
|
+
if (!file) return;
|
|
861
|
+
const reader = new FileReader();
|
|
862
|
+
reader.onload = () => {
|
|
863
|
+
const imageData =
|
|
864
|
+
typeof reader.result === 'string' ? reader.result : null;
|
|
865
|
+
if (imageData) publishImage(imageData, 'gallery', null);
|
|
866
|
+
};
|
|
867
|
+
reader.readAsDataURL(file);
|
|
868
|
+
if (target) target.value = '';
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
const containerStyle = {
|
|
872
|
+
width: '100%',
|
|
873
|
+
height: '100%',
|
|
874
|
+
display: 'flex',
|
|
875
|
+
flexDirection: 'column',
|
|
876
|
+
backgroundColor: '#000',
|
|
877
|
+
position: 'relative',
|
|
878
|
+
overflow: 'hidden',
|
|
879
|
+
fontFamily: theme.fonts.base,
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
// Inline styles for shadow-DOM host + global resets so the layout matches
|
|
883
|
+
// the id-scanner viewport (where these come from index.css globally).
|
|
884
|
+
const hostStyles = `
|
|
885
|
+
:host { display: block; width: 100%; height: 100%; }
|
|
886
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
887
|
+
`;
|
|
888
|
+
|
|
889
|
+
/* ---- Desktop layout ----
|
|
890
|
+
Matches the legacy `<document-capture>` visual style: optional nav
|
|
891
|
+
buttons at top, constrained video with a simple solid border whose
|
|
892
|
+
colour reflects detection state, title + dynamic feedback text below
|
|
893
|
+
the video, and the legacy concentric-circle capture button.
|
|
894
|
+
Auto-capture detection logic (useCardDetection) is unchanged.
|
|
895
|
+
*/
|
|
896
|
+
if (!isMobileDevice) {
|
|
897
|
+
const borderColor = (() => {
|
|
898
|
+
if (
|
|
899
|
+
visibleComplianceState === COMPLIANCE_STATES.STABLE ||
|
|
900
|
+
visibleComplianceState === COMPLIANCE_STATES.SUCCESS ||
|
|
901
|
+
visibleComplianceState === COMPLIANCE_STATES.CAPTURING
|
|
902
|
+
) {
|
|
903
|
+
return '#2CC05C';
|
|
904
|
+
}
|
|
905
|
+
if (visibleComplianceState === COMPLIANCE_STATES.DETECTING) {
|
|
906
|
+
return '#F59E0B';
|
|
907
|
+
}
|
|
908
|
+
return '#9394ab';
|
|
909
|
+
})();
|
|
910
|
+
|
|
911
|
+
const titleLabel = title || `Submit ${sideOfId} of ID`;
|
|
912
|
+
|
|
913
|
+
return (
|
|
914
|
+
<div
|
|
915
|
+
className="document-auto-capture document-auto-capture--desktop"
|
|
916
|
+
style={{
|
|
917
|
+
width: '100%',
|
|
918
|
+
height: '100%',
|
|
919
|
+
display: 'flex',
|
|
920
|
+
flexDirection: 'column',
|
|
921
|
+
backgroundColor: '#fff',
|
|
922
|
+
fontFamily: theme.fonts.base,
|
|
923
|
+
boxSizing: 'border-box',
|
|
924
|
+
}}
|
|
925
|
+
>
|
|
926
|
+
<style>{hostStyles}</style>
|
|
927
|
+
|
|
928
|
+
{allowGalleryUpload && (
|
|
929
|
+
<input
|
|
930
|
+
ref={galleryInputRef}
|
|
931
|
+
type="file"
|
|
932
|
+
accept="image/*"
|
|
933
|
+
onChange={handleGalleryChange}
|
|
934
|
+
style={{ display: 'none' }}
|
|
935
|
+
/>
|
|
936
|
+
)}
|
|
937
|
+
|
|
938
|
+
{/* Navigation row — reuses the shared <smileid-navigation> element.
|
|
939
|
+
Light desktop chrome: dark icons on a faint grey pill, overriding
|
|
940
|
+
the element's default translucent-on-dark styling via CSS vars. */}
|
|
941
|
+
{showNavigation && (
|
|
942
|
+
<div style={{ padding: '0.75rem 1rem 0' }}>
|
|
943
|
+
{/* @ts-expect-error preact-custom-element lacks ref/attr types */}
|
|
944
|
+
<smileid-navigation
|
|
945
|
+
ref={navigationRef}
|
|
946
|
+
style={{
|
|
947
|
+
width: '100%',
|
|
948
|
+
'--smileid-navigation-button-bg': 'rgba(0,0,0,0.08)',
|
|
949
|
+
'--smileid-navigation-icon-color': 'rgba(0,0,0,0.7)',
|
|
950
|
+
'--smileid-navigation-focus-color': themeColor,
|
|
951
|
+
}}
|
|
952
|
+
/>
|
|
953
|
+
</div>
|
|
954
|
+
)}
|
|
955
|
+
|
|
956
|
+
{/* Video area */}
|
|
957
|
+
<div
|
|
958
|
+
style={{
|
|
959
|
+
flex: 1,
|
|
960
|
+
display: 'flex',
|
|
961
|
+
alignItems: 'center',
|
|
962
|
+
justifyContent: 'center',
|
|
963
|
+
padding: '1rem',
|
|
964
|
+
overflow: 'hidden',
|
|
965
|
+
}}
|
|
966
|
+
>
|
|
967
|
+
<div
|
|
968
|
+
ref={cameraViewportRef}
|
|
969
|
+
style={{
|
|
970
|
+
position: 'relative',
|
|
971
|
+
width: '100%',
|
|
972
|
+
maxWidth: 480,
|
|
973
|
+
aspectRatio: `${guideAspectRatio} / 1`,
|
|
974
|
+
borderRadius: 4,
|
|
975
|
+
overflow: 'hidden',
|
|
976
|
+
border: `4px solid ${borderColor}`,
|
|
977
|
+
transition: 'border-color 0.25s ease',
|
|
978
|
+
backgroundColor: '#000',
|
|
979
|
+
}}
|
|
980
|
+
>
|
|
981
|
+
<video
|
|
982
|
+
ref={videoRef}
|
|
983
|
+
playsInline
|
|
984
|
+
muted
|
|
985
|
+
style={{
|
|
986
|
+
position: 'absolute',
|
|
987
|
+
top: 0,
|
|
988
|
+
left: 0,
|
|
989
|
+
width: '100%',
|
|
990
|
+
height: '100%',
|
|
991
|
+
objectFit: 'cover',
|
|
992
|
+
display: 'block',
|
|
993
|
+
}}
|
|
994
|
+
/>
|
|
995
|
+
{/* Debug-only: outline the active detection ROI so threshold
|
|
996
|
+
issues (wall-hug, overflow, fill %) can be judged visually. */}
|
|
997
|
+
{showDebug && debugRoi ? (
|
|
998
|
+
<div
|
|
999
|
+
style={{
|
|
1000
|
+
position: 'absolute',
|
|
1001
|
+
left: debugRoi.x,
|
|
1002
|
+
top: debugRoi.y,
|
|
1003
|
+
width: debugRoi.w,
|
|
1004
|
+
height: debugRoi.h,
|
|
1005
|
+
border: '2px dashed #ff3b30',
|
|
1006
|
+
boxSizing: 'border-box',
|
|
1007
|
+
pointerEvents: 'none',
|
|
1008
|
+
zIndex: 5,
|
|
1009
|
+
}}
|
|
1010
|
+
>
|
|
1011
|
+
<span
|
|
1012
|
+
style={{
|
|
1013
|
+
position: 'absolute',
|
|
1014
|
+
top: 2,
|
|
1015
|
+
left: 4,
|
|
1016
|
+
color: '#ff3b30',
|
|
1017
|
+
font: '600 10px/1 sans-serif',
|
|
1018
|
+
textShadow: '0 0 2px rgba(0,0,0,0.8)',
|
|
1019
|
+
}}
|
|
1020
|
+
>
|
|
1021
|
+
ROI
|
|
1022
|
+
</span>
|
|
1023
|
+
</div>
|
|
1024
|
+
) : null}
|
|
1025
|
+
</div>
|
|
1026
|
+
</div>
|
|
1027
|
+
|
|
1028
|
+
{/* Footer: title, feedback text, capture button */}
|
|
1029
|
+
<div
|
|
1030
|
+
style={{
|
|
1031
|
+
padding: '0 1.5rem 1.5rem',
|
|
1032
|
+
display: 'flex',
|
|
1033
|
+
flexDirection: 'column',
|
|
1034
|
+
alignItems: 'center',
|
|
1035
|
+
gap: '0.5rem',
|
|
1036
|
+
textAlign: 'center',
|
|
1037
|
+
}}
|
|
1038
|
+
>
|
|
1039
|
+
<h2
|
|
1040
|
+
style={{
|
|
1041
|
+
margin: 0,
|
|
1042
|
+
fontSize: '1rem',
|
|
1043
|
+
fontWeight: 700,
|
|
1044
|
+
color: themeColor,
|
|
1045
|
+
}}
|
|
1046
|
+
>
|
|
1047
|
+
{titleLabel}
|
|
1048
|
+
</h2>
|
|
1049
|
+
<p
|
|
1050
|
+
style={{
|
|
1051
|
+
margin: 0,
|
|
1052
|
+
fontSize: '0.9rem',
|
|
1053
|
+
color: '#333',
|
|
1054
|
+
minHeight: '1.25rem',
|
|
1055
|
+
}}
|
|
1056
|
+
>
|
|
1057
|
+
{visibleFeedback}
|
|
1058
|
+
</p>
|
|
1059
|
+
|
|
1060
|
+
<div
|
|
1061
|
+
style={{
|
|
1062
|
+
display: 'flex',
|
|
1063
|
+
gap: '1.25rem',
|
|
1064
|
+
alignItems: 'center',
|
|
1065
|
+
justifyContent: 'center',
|
|
1066
|
+
marginTop: '0.75rem',
|
|
1067
|
+
}}
|
|
1068
|
+
>
|
|
1069
|
+
{allowGalleryUpload && (
|
|
1070
|
+
<GalleryButton onClick={handlePickFromGallery} />
|
|
1071
|
+
)}
|
|
1072
|
+
{/* The manual shutter only appears when it can actually be used
|
|
1073
|
+
(showManualButton): immediately for manualCaptureOnly, after
|
|
1074
|
+
the auto-capture timeout fallback fires in autoCapture, or on
|
|
1075
|
+
CV load failure; never in autoCaptureOnly. Auto-capture state
|
|
1076
|
+
is conveyed by the video border, so no progress ring is needed
|
|
1077
|
+
while the shutter is hidden. */}
|
|
1078
|
+
{showManualButton && (
|
|
1079
|
+
<DesktopCaptureButton
|
|
1080
|
+
progress={
|
|
1081
|
+
visibleComplianceState === COMPLIANCE_STATES.STABLE
|
|
1082
|
+
? captureProgress
|
|
1083
|
+
: 0
|
|
1084
|
+
}
|
|
1085
|
+
themeColor={themeColor}
|
|
1086
|
+
disabled={complianceState === COMPLIANCE_STATES.SUCCESS}
|
|
1087
|
+
onClick={triggerManualCapture}
|
|
1088
|
+
/>
|
|
1089
|
+
)}
|
|
1090
|
+
</div>
|
|
1091
|
+
|
|
1092
|
+
{captureMode === 'autoCaptureOnly' && cvLoadFailed && (
|
|
1093
|
+
<p
|
|
1094
|
+
style={{
|
|
1095
|
+
color: theme.colors.error,
|
|
1096
|
+
fontSize: '0.8rem',
|
|
1097
|
+
textAlign: 'center',
|
|
1098
|
+
margin: 0,
|
|
1099
|
+
}}
|
|
1100
|
+
>
|
|
1101
|
+
{translate('document.autoCapture.error.cvLoadFailed')}
|
|
1102
|
+
</p>
|
|
1103
|
+
)}
|
|
1104
|
+
</div>
|
|
1105
|
+
|
|
1106
|
+
{/* __SMILE_DEBUG__ is a build-time literal → this whole branch (and the
|
|
1107
|
+
TuningPanel import) is dead-code-eliminated from production bundles;
|
|
1108
|
+
showDebug then applies the runtime ?debug opt-in in dev + preview. */}
|
|
1109
|
+
{__SMILE_DEBUG__ && showDebug && (
|
|
1110
|
+
<TuningPanel
|
|
1111
|
+
settings={settings}
|
|
1112
|
+
updateSetting={updateSetting}
|
|
1113
|
+
debugInfo={debugInfo}
|
|
1114
|
+
/>
|
|
1115
|
+
)}
|
|
1116
|
+
</div>
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
return (
|
|
1121
|
+
<div className="document-auto-capture" style={containerStyle}>
|
|
1122
|
+
<style>{hostStyles}</style>
|
|
1123
|
+
|
|
1124
|
+
{allowGalleryUpload && (
|
|
1125
|
+
<input
|
|
1126
|
+
ref={galleryInputRef}
|
|
1127
|
+
type="file"
|
|
1128
|
+
accept="image/*"
|
|
1129
|
+
onChange={handleGalleryChange}
|
|
1130
|
+
style={{ display: 'none' }}
|
|
1131
|
+
/>
|
|
1132
|
+
)}
|
|
1133
|
+
|
|
1134
|
+
{/* Camera viewport — fills the host absolutely. The rotated overlay
|
|
1135
|
+
below uses the observed pixel dimensions of this box (via
|
|
1136
|
+
ResizeObserver) so it matches the visible camera area regardless
|
|
1137
|
+
of whether the host fills the page viewport or a smaller parent. */}
|
|
1138
|
+
<div
|
|
1139
|
+
ref={cameraViewportRef}
|
|
1140
|
+
style={{
|
|
1141
|
+
position: 'absolute',
|
|
1142
|
+
inset: 0,
|
|
1143
|
+
overflow: 'hidden',
|
|
1144
|
+
zIndex: 1,
|
|
1145
|
+
}}
|
|
1146
|
+
>
|
|
1147
|
+
<video
|
|
1148
|
+
ref={videoRef}
|
|
1149
|
+
playsInline
|
|
1150
|
+
muted
|
|
1151
|
+
style={{
|
|
1152
|
+
position: 'absolute',
|
|
1153
|
+
top: 0,
|
|
1154
|
+
left: 0,
|
|
1155
|
+
width: '100%',
|
|
1156
|
+
height: '100%',
|
|
1157
|
+
objectFit: 'cover',
|
|
1158
|
+
display: 'block',
|
|
1159
|
+
}}
|
|
1160
|
+
/>
|
|
1161
|
+
|
|
1162
|
+
{/* UI overlay container — rotated 90° CW on portrait phones with landscape doc types */}
|
|
1163
|
+
<div
|
|
1164
|
+
style={{
|
|
1165
|
+
position: 'absolute',
|
|
1166
|
+
top: 0,
|
|
1167
|
+
left: 0,
|
|
1168
|
+
...(shouldRotateUi
|
|
1169
|
+
? {
|
|
1170
|
+
// Rotated 90° CW — swap parent box dimensions so the
|
|
1171
|
+
// rotated rectangle covers the camera viewport exactly.
|
|
1172
|
+
width: viewportBox.h ? `${viewportBox.h}px` : '100%',
|
|
1173
|
+
height: viewportBox.w ? `${viewportBox.w}px` : '100%',
|
|
1174
|
+
transformOrigin: '0 0',
|
|
1175
|
+
transform: 'rotate(90deg) translateY(-100%)',
|
|
1176
|
+
}
|
|
1177
|
+
: {
|
|
1178
|
+
width: '100%',
|
|
1179
|
+
height: '100%',
|
|
1180
|
+
}),
|
|
1181
|
+
pointerEvents: 'none',
|
|
1182
|
+
display: 'flex',
|
|
1183
|
+
flexDirection: 'column',
|
|
1184
|
+
zIndex: 5,
|
|
1185
|
+
}}
|
|
1186
|
+
>
|
|
1187
|
+
{/* Top controls — reuses the shared <smileid-navigation> element,
|
|
1188
|
+
spanning the top so Back lands top-left and Close top-right. The
|
|
1189
|
+
element's default translucent-on-dark styling already matches the
|
|
1190
|
+
fullscreen camera chrome. */}
|
|
1191
|
+
{showNavigation && (
|
|
1192
|
+
<div
|
|
1193
|
+
style={{
|
|
1194
|
+
position: 'absolute',
|
|
1195
|
+
top: 32,
|
|
1196
|
+
left: 16,
|
|
1197
|
+
right: 16,
|
|
1198
|
+
zIndex: 10,
|
|
1199
|
+
pointerEvents: 'auto',
|
|
1200
|
+
}}
|
|
1201
|
+
>
|
|
1202
|
+
{/* @ts-expect-error preact-custom-element lacks ref/attr types */}
|
|
1203
|
+
<smileid-navigation
|
|
1204
|
+
ref={navigationRef}
|
|
1205
|
+
style={{
|
|
1206
|
+
width: '100%',
|
|
1207
|
+
'--smileid-navigation-button-bg': 'rgba(0,0,0,0.55)',
|
|
1208
|
+
'--smileid-navigation-icon-color': '#fff',
|
|
1209
|
+
}}
|
|
1210
|
+
/>
|
|
1211
|
+
</div>
|
|
1212
|
+
)}
|
|
1213
|
+
|
|
1214
|
+
{/* Detection overlay with guide box */}
|
|
1215
|
+
<Overlay
|
|
1216
|
+
complianceState={visibleComplianceState}
|
|
1217
|
+
debugPath={debugPath}
|
|
1218
|
+
showDebug={showDebug}
|
|
1219
|
+
guideAspectRatio={guideAspectRatio}
|
|
1220
|
+
detectedDocType={detectedDocType}
|
|
1221
|
+
sideOfId={sideOfId}
|
|
1222
|
+
isRotated={shouldRotateUi}
|
|
1223
|
+
/>
|
|
1224
|
+
|
|
1225
|
+
{/* Side capture-progress button */}
|
|
1226
|
+
{showSideSpinner && (
|
|
1227
|
+
<div
|
|
1228
|
+
style={{
|
|
1229
|
+
position: 'absolute',
|
|
1230
|
+
right: 34,
|
|
1231
|
+
top: '50%',
|
|
1232
|
+
transform: 'translateY(-50%)',
|
|
1233
|
+
zIndex: 11,
|
|
1234
|
+
pointerEvents: 'auto',
|
|
1235
|
+
}}
|
|
1236
|
+
>
|
|
1237
|
+
<CaptureButton
|
|
1238
|
+
progress={spinnerProgress}
|
|
1239
|
+
disabled={false}
|
|
1240
|
+
appearance="light"
|
|
1241
|
+
onClick={() => {}}
|
|
1242
|
+
/>
|
|
1243
|
+
</div>
|
|
1244
|
+
)}
|
|
1245
|
+
|
|
1246
|
+
{/* Side manual capture button — anchor the CaptureButton (72px) at
|
|
1247
|
+
the vertical center so the gallery button stacks below without
|
|
1248
|
+
shifting the shutter off-center. */}
|
|
1249
|
+
{(useSideManualCapture || showSideGalleryButton) && (
|
|
1250
|
+
<div
|
|
1251
|
+
style={{
|
|
1252
|
+
position: 'absolute',
|
|
1253
|
+
right: 22,
|
|
1254
|
+
top: 'calc(50% - 36px)',
|
|
1255
|
+
zIndex: 12,
|
|
1256
|
+
display: 'flex',
|
|
1257
|
+
flexDirection: 'column',
|
|
1258
|
+
gap: 10,
|
|
1259
|
+
alignItems: 'center',
|
|
1260
|
+
pointerEvents: 'auto',
|
|
1261
|
+
}}
|
|
1262
|
+
>
|
|
1263
|
+
{useSideManualCapture && (
|
|
1264
|
+
<CaptureButton
|
|
1265
|
+
progress={sideButtonProgress}
|
|
1266
|
+
disabled={complianceState === COMPLIANCE_STATES.SUCCESS}
|
|
1267
|
+
appearance="light"
|
|
1268
|
+
onClick={triggerManualCapture}
|
|
1269
|
+
/>
|
|
1270
|
+
)}
|
|
1271
|
+
{showSideGalleryButton && (
|
|
1272
|
+
<GalleryButton onClick={handlePickFromGallery} />
|
|
1273
|
+
)}
|
|
1274
|
+
</div>
|
|
1275
|
+
)}
|
|
1276
|
+
|
|
1277
|
+
{/* Floating capture status pill */}
|
|
1278
|
+
<div
|
|
1279
|
+
style={{
|
|
1280
|
+
position: 'absolute',
|
|
1281
|
+
left: '50%',
|
|
1282
|
+
bottom: shouldRotateUi ? 5 : 184,
|
|
1283
|
+
transform: 'translateX(-50%)',
|
|
1284
|
+
backgroundColor: 'rgba(35,35,35,0.95)',
|
|
1285
|
+
borderRadius: 14,
|
|
1286
|
+
border: '1px solid rgba(255,255,255,0.08)',
|
|
1287
|
+
padding: '9px 20px',
|
|
1288
|
+
minWidth: 220,
|
|
1289
|
+
maxWidth: 'calc(100% - 32px)',
|
|
1290
|
+
textAlign: 'center',
|
|
1291
|
+
boxShadow: '0 8px 24px rgba(0,0,0,0.28)',
|
|
1292
|
+
pointerEvents: 'auto',
|
|
1293
|
+
}}
|
|
1294
|
+
>
|
|
1295
|
+
<span
|
|
1296
|
+
style={{
|
|
1297
|
+
color: '#fff',
|
|
1298
|
+
fontSize: 16,
|
|
1299
|
+
fontWeight: 700,
|
|
1300
|
+
fontFamily: theme.fonts.base,
|
|
1301
|
+
letterSpacing: '-0.1px',
|
|
1302
|
+
}}
|
|
1303
|
+
>
|
|
1304
|
+
{visibleFeedback}
|
|
1305
|
+
</span>
|
|
1306
|
+
</div>
|
|
1307
|
+
</div>
|
|
1308
|
+
|
|
1309
|
+
{/* Manual fallback button — only shown in portrait layout (not rotated).
|
|
1310
|
+
Matches the landscape side-button styling: bare CaptureButton with
|
|
1311
|
+
no pill background. The CaptureButton is centered absolutely; the
|
|
1312
|
+
gallery button is anchored to its right so the shutter stays on
|
|
1313
|
+
the horizontal centerline regardless of which controls are shown. */}
|
|
1314
|
+
{!shouldRotateUi &&
|
|
1315
|
+
(showManualCaptureControl || showBottomGalleryButton) &&
|
|
1316
|
+
!useSideManualCapture && (
|
|
1317
|
+
<>
|
|
1318
|
+
{showManualCaptureControl && (
|
|
1319
|
+
<div
|
|
1320
|
+
style={{
|
|
1321
|
+
position: 'absolute',
|
|
1322
|
+
left: '50%',
|
|
1323
|
+
bottom: 60,
|
|
1324
|
+
transform: 'translateX(-50%)',
|
|
1325
|
+
zIndex: 12,
|
|
1326
|
+
}}
|
|
1327
|
+
>
|
|
1328
|
+
<CaptureButton
|
|
1329
|
+
progress={
|
|
1330
|
+
visibleComplianceState === COMPLIANCE_STATES.STABLE
|
|
1331
|
+
? captureProgress
|
|
1332
|
+
: 0
|
|
1333
|
+
}
|
|
1334
|
+
disabled={complianceState === COMPLIANCE_STATES.SUCCESS}
|
|
1335
|
+
appearance="light"
|
|
1336
|
+
onClick={triggerManualCapture}
|
|
1337
|
+
/>
|
|
1338
|
+
</div>
|
|
1339
|
+
)}
|
|
1340
|
+
{showBottomGalleryButton && (
|
|
1341
|
+
<div
|
|
1342
|
+
style={{
|
|
1343
|
+
position: 'absolute',
|
|
1344
|
+
// Shutter is 72 px wide and centered; gallery sits to its
|
|
1345
|
+
// left at 36 px half-width + 24 px gap so the shutter
|
|
1346
|
+
// stays exactly on the centerline.
|
|
1347
|
+
left: showManualCaptureControl
|
|
1348
|
+
? 'calc(50% - 36px - 24px)'
|
|
1349
|
+
: '50%',
|
|
1350
|
+
bottom: 60,
|
|
1351
|
+
transform: showManualCaptureControl
|
|
1352
|
+
? 'translateX(-100%)'
|
|
1353
|
+
: 'translateX(-50%)',
|
|
1354
|
+
zIndex: 12,
|
|
1355
|
+
}}
|
|
1356
|
+
>
|
|
1357
|
+
<GalleryButton onClick={handlePickFromGallery} />
|
|
1358
|
+
</div>
|
|
1359
|
+
)}
|
|
1360
|
+
{captureMode === 'autoCaptureOnly' && cvLoadFailed && (
|
|
1361
|
+
<p
|
|
1362
|
+
style={{
|
|
1363
|
+
position: 'absolute',
|
|
1364
|
+
left: '50%',
|
|
1365
|
+
bottom: 28,
|
|
1366
|
+
transform: 'translateX(-50%)',
|
|
1367
|
+
zIndex: 12,
|
|
1368
|
+
color: theme.colors.error,
|
|
1369
|
+
fontSize: '0.8rem',
|
|
1370
|
+
textAlign: 'center',
|
|
1371
|
+
margin: 0,
|
|
1372
|
+
}}
|
|
1373
|
+
>
|
|
1374
|
+
{translate('document.autoCapture.error.cvLoadFailed')}
|
|
1375
|
+
</p>
|
|
1376
|
+
)}
|
|
1377
|
+
</>
|
|
1378
|
+
)}
|
|
1379
|
+
</div>
|
|
1380
|
+
|
|
1381
|
+
{/* Tuning panel (debug mode only) */}
|
|
1382
|
+
{/* Build-time gate → tree-shaken in production (see note above). */}
|
|
1383
|
+
{__SMILE_DEBUG__ && showDebug && (
|
|
1384
|
+
<TuningPanel
|
|
1385
|
+
settings={settings}
|
|
1386
|
+
updateSetting={updateSetting}
|
|
1387
|
+
debugInfo={debugInfo}
|
|
1388
|
+
/>
|
|
1389
|
+
)}
|
|
1390
|
+
</div>
|
|
1391
|
+
);
|
|
1392
|
+
};
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* `<document-auto-capture>` — auto-capture document scanner.
|
|
1396
|
+
*
|
|
1397
|
+
* Drop-in replacement for the legacy `<document-capture>` element used by
|
|
1398
|
+
* `DocumentCaptureScreens`. The orchestrator renders both front + back
|
|
1399
|
+
* instances simultaneously and toggles their `hidden` attribute, so this
|
|
1400
|
+
* wrapper observes the host's `hidden` attribute and only mounts the heavy
|
|
1401
|
+
* inner component (camera + OpenCV detection loop) while it's visible.
|
|
1402
|
+
*/
|
|
1403
|
+
const DocumentAutoCapture: FunctionComponent<Props> = (props) => {
|
|
1404
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
1405
|
+
const [isActive, setIsActive] = useState(false);
|
|
1406
|
+
|
|
1407
|
+
useLayoutEffect(() => {
|
|
1408
|
+
const root = rootRef.current?.getRootNode();
|
|
1409
|
+
const host =
|
|
1410
|
+
root && root instanceof ShadowRoot ? (root.host as HTMLElement) : null;
|
|
1411
|
+
|
|
1412
|
+
if (!host) {
|
|
1413
|
+
// Not inside a shadow root (e.g. test harness) — always render.
|
|
1414
|
+
setIsActive(true);
|
|
1415
|
+
return undefined;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
const update = () => setIsActive(!host.hasAttribute('hidden'));
|
|
1419
|
+
update();
|
|
1420
|
+
|
|
1421
|
+
const obs = new MutationObserver(update);
|
|
1422
|
+
obs.observe(host, { attributes: true, attributeFilter: ['hidden'] });
|
|
1423
|
+
return () => obs.disconnect();
|
|
1424
|
+
}, []);
|
|
1425
|
+
|
|
1426
|
+
return (
|
|
1427
|
+
<div
|
|
1428
|
+
ref={rootRef}
|
|
1429
|
+
style={{ width: '100%', height: '100%', display: 'contents' }}
|
|
1430
|
+
>
|
|
1431
|
+
{isActive ? <DocumentAutoCaptureInner {...props} /> : null}
|
|
1432
|
+
</div>
|
|
1433
|
+
);
|
|
1434
|
+
};
|
|
1435
|
+
|
|
1436
|
+
if (
|
|
1437
|
+
typeof customElements !== 'undefined' &&
|
|
1438
|
+
!customElements.get('document-auto-capture')
|
|
1439
|
+
) {
|
|
1440
|
+
register(
|
|
1441
|
+
DocumentAutoCapture,
|
|
1442
|
+
'document-auto-capture',
|
|
1443
|
+
[
|
|
1444
|
+
'document-type',
|
|
1445
|
+
'auto-capture',
|
|
1446
|
+
'auto-capture-timeout',
|
|
1447
|
+
'side-of-id',
|
|
1448
|
+
'show-navigation',
|
|
1449
|
+
'allow-gallery-upload',
|
|
1450
|
+
'document-capture-modes',
|
|
1451
|
+
'sync-roi-to-guide',
|
|
1452
|
+
'title',
|
|
1453
|
+
],
|
|
1454
|
+
{ shadow: true },
|
|
1455
|
+
);
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
export default DocumentAutoCapture;
|