@enbox/browser 0.3.57 → 0.3.59
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/browser.mjs +14 -14
- package/dist/browser.mjs.map +4 -4
- package/dist/esm/dweb-connect-client.js +14 -15
- package/dist/esm/dweb-connect-client.js.map +1 -1
- package/dist/esm/dweb-connect-wallet.js +11 -13
- package/dist/esm/dweb-connect-wallet.js.map +1 -1
- package/dist/esm/handled-deferred.js +24 -0
- package/dist/esm/handled-deferred.js.map +1 -0
- package/dist/esm/index.js +7 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/relay-connect-runner.js +55 -26
- package/dist/esm/relay-connect-runner.js.map +1 -1
- package/dist/esm/ui/connect-modal.js +111 -77
- package/dist/esm/ui/connect-modal.js.map +1 -1
- package/dist/esm/ui/qr.js +121 -64
- package/dist/esm/ui/qr.js.map +1 -1
- package/dist/esm/web-features.js +54 -41
- package/dist/esm/web-features.js.map +1 -1
- package/dist/types/dweb-connect-client.d.ts +4 -4
- package/dist/types/dweb-connect-client.d.ts.map +1 -1
- package/dist/types/dweb-connect-wallet.d.ts +3 -3
- package/dist/types/dweb-connect-wallet.d.ts.map +1 -1
- package/dist/types/handled-deferred.d.ts +22 -0
- package/dist/types/handled-deferred.d.ts.map +1 -0
- package/dist/types/index.d.ts +6 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/relay-connect-runner.d.ts +1 -1
- package/dist/types/relay-connect-runner.d.ts.map +1 -1
- package/dist/types/ui/connect-modal.d.ts.map +1 -1
- package/dist/types/ui/qr.d.ts.map +1 -1
- package/dist/types/web-features.d.ts.map +1 -1
- package/package.json +11 -10
- package/src/dweb-connect-client.ts +15 -15
- package/src/dweb-connect-wallet.ts +14 -14
- package/src/handled-deferred.ts +38 -0
- package/src/index.ts +50 -0
- package/src/relay-connect-runner.ts +71 -30
- package/src/ui/connect-modal.ts +113 -72
- package/src/ui/qr.ts +145 -69
- package/src/web-features.ts +55 -43
package/src/ui/connect-modal.ts
CHANGED
|
@@ -346,6 +346,19 @@ function createRelaySession(): RelaySession {
|
|
|
346
346
|
return session as RelaySession;
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Wraps `settler` so the returned thunk runs it through `settle()`. Defined
|
|
351
|
+
* at module scope — not as an inline closure inside each render function —
|
|
352
|
+
* so the returned closure is lexically nested one level (inside this
|
|
353
|
+
* function), not several levels inside `runConnectModal()`'s own render
|
|
354
|
+
* closures (Sonar S2004). `settle` and `settler` are threaded through
|
|
355
|
+
* unchanged, so behavior is identical to writing `() => settle(settler)`
|
|
356
|
+
* inline.
|
|
357
|
+
*/
|
|
358
|
+
function settleWith(settle: (settler: () => void) => void, settler: () => void): () => void {
|
|
359
|
+
return () => settle(settler);
|
|
360
|
+
}
|
|
361
|
+
|
|
349
362
|
/**
|
|
350
363
|
* Opens the connect modal and resolves when the session ends.
|
|
351
364
|
*
|
|
@@ -410,7 +423,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
410
423
|
const theme = options.theme ?? {};
|
|
411
424
|
const appearance = theme.appearance ?? 'auto';
|
|
412
425
|
if (appearance !== 'auto') {
|
|
413
|
-
modal.
|
|
426
|
+
modal.dataset.appearance = appearance;
|
|
414
427
|
}
|
|
415
428
|
|
|
416
429
|
// Dapp palette overrides land as inline custom properties: setProperty
|
|
@@ -420,9 +433,8 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
420
433
|
const hasPaletteOverrides = theme.accent !== undefined || theme.accentContrast !== undefined
|
|
421
434
|
|| theme.light !== undefined || theme.dark !== undefined;
|
|
422
435
|
const applyPaletteTokens = (): void => {
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
: appearance;
|
|
436
|
+
const systemScheme = systemDark?.matches === true ? 'dark' : 'light';
|
|
437
|
+
const scheme = appearance === 'auto' ? systemScheme : appearance;
|
|
426
438
|
const palette: ConnectModalPalette = {
|
|
427
439
|
accent : theme.accent,
|
|
428
440
|
accentContrast : theme.accentContrast,
|
|
@@ -452,6 +464,11 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
452
464
|
let remintAt: number | undefined;
|
|
453
465
|
let popupBusy = false;
|
|
454
466
|
let pinResolve: ((pin: string) => void) | undefined;
|
|
467
|
+
// Proxy pinResolve reads/clears for buildPinInputs (module scope), whose
|
|
468
|
+
// per-input closures would otherwise be nested inside renderPin()'s own
|
|
469
|
+
// executor chain too deeply (Sonar S2004).
|
|
470
|
+
const getPinResolve = (): ((pin: string) => void) | undefined => pinResolve;
|
|
471
|
+
const clearPinResolve = (): void => { pinResolve = undefined; };
|
|
455
472
|
const discoveryCache = new Map<string, Promise<string | undefined>>();
|
|
456
473
|
|
|
457
474
|
// Collapsed identity row: the selected wallet plus the next catalog
|
|
@@ -489,7 +506,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
489
506
|
document.removeEventListener('keydown', onKeydown);
|
|
490
507
|
document.removeEventListener('visibilitychange', onVisibilityReturn);
|
|
491
508
|
systemDark?.removeEventListener?.('change', onSchemeChange);
|
|
492
|
-
try {
|
|
509
|
+
try { host.remove(); } catch { /* best effort */ }
|
|
493
510
|
};
|
|
494
511
|
|
|
495
512
|
const settle = (settler: () => void): void => {
|
|
@@ -504,8 +521,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
504
521
|
writeLastChoice(deps.storage, { method, walletUrl: walletUrl ?? '' });
|
|
505
522
|
}
|
|
506
523
|
renderConnected();
|
|
507
|
-
|
|
508
|
-
setTimeout(finish, 1_200);
|
|
524
|
+
setTimeout(settleWith(settle, () => resolve(result)), 1_200);
|
|
509
525
|
};
|
|
510
526
|
|
|
511
527
|
const cancelModal = (): void => settle(() => reject(new Error('[@enbox/browser] Connect cancelled.')));
|
|
@@ -659,54 +675,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
659
675
|
boxes.classList.add('shake');
|
|
660
676
|
}
|
|
661
677
|
|
|
662
|
-
const inputs
|
|
663
|
-
for (let i = 0; i < PIN_LENGTH; i++) {
|
|
664
|
-
const input = document.createElement('input');
|
|
665
|
-
input.className = 'pin-input';
|
|
666
|
-
input.type = 'text';
|
|
667
|
-
input.inputMode = 'numeric';
|
|
668
|
-
input.maxLength = 1;
|
|
669
|
-
input.setAttribute('aria-label', `Code digit ${i + 1}`);
|
|
670
|
-
inputs.push(input);
|
|
671
|
-
boxes.appendChild(input);
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
const submitIfComplete = (): void => {
|
|
675
|
-
const code = inputs.map((i) => i.value).join('');
|
|
676
|
-
if (code.length === PIN_LENGTH && pinResolve !== undefined) {
|
|
677
|
-
const resolveOnce = pinResolve;
|
|
678
|
-
pinResolve = undefined;
|
|
679
|
-
renderBusy('Checking the code…');
|
|
680
|
-
resolveOnce(code);
|
|
681
|
-
}
|
|
682
|
-
};
|
|
683
|
-
|
|
684
|
-
inputs.forEach((input, index) => {
|
|
685
|
-
input.addEventListener('input', () => {
|
|
686
|
-
input.value = input.value.replace(/\D/g, '').slice(0, 1);
|
|
687
|
-
if (input.value !== '' && index + 1 < inputs.length) {
|
|
688
|
-
inputs[index + 1].focus();
|
|
689
|
-
}
|
|
690
|
-
submitIfComplete();
|
|
691
|
-
});
|
|
692
|
-
input.addEventListener('keydown', (event) => {
|
|
693
|
-
if (event.key === 'Backspace' && input.value === '' && index > 0) {
|
|
694
|
-
inputs[index - 1].focus();
|
|
695
|
-
}
|
|
696
|
-
});
|
|
697
|
-
input.addEventListener('paste', (event) => {
|
|
698
|
-
const digits = (event.clipboardData?.getData('text') ?? '').replace(/\D/g, '').slice(0, PIN_LENGTH);
|
|
699
|
-
if (digits.length === 0) { return; }
|
|
700
|
-
event.preventDefault();
|
|
701
|
-
digits.split('').forEach((digit, digitIndex) => {
|
|
702
|
-
if (inputs[digitIndex] !== undefined) {
|
|
703
|
-
inputs[digitIndex].value = digit;
|
|
704
|
-
}
|
|
705
|
-
});
|
|
706
|
-
inputs[Math.min(digits.length, PIN_LENGTH) - 1]?.focus();
|
|
707
|
-
submitIfComplete();
|
|
708
|
-
});
|
|
709
|
-
});
|
|
678
|
+
const inputs = buildPinInputs(boxes, getPinResolve, clearPinResolve, renderBusy);
|
|
710
679
|
|
|
711
680
|
setStage(
|
|
712
681
|
el('p', 'stage-caption', `Enter the code shown in ${walletName}`),
|
|
@@ -730,7 +699,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
730
699
|
setStage(
|
|
731
700
|
el('p', 'stage-caption', 'No problem — nothing was shared.'),
|
|
732
701
|
stageButton('Start over', () => { void startMethod(); }),
|
|
733
|
-
stageLinkButton('Close', (
|
|
702
|
+
stageLinkButton('Close', settleWith(settle, () => resolve(undefined))),
|
|
734
703
|
);
|
|
735
704
|
};
|
|
736
705
|
|
|
@@ -738,7 +707,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
738
707
|
setStage(
|
|
739
708
|
el('p', 'stage-caption', message),
|
|
740
709
|
stageButton('Start over', () => { void startMethod(); }),
|
|
741
|
-
stageLinkButton('Close', (
|
|
710
|
+
stageLinkButton('Close', settleWith(settle, () => reject(error ?? new Error(`[@enbox/browser] ${message}`)))),
|
|
742
711
|
);
|
|
743
712
|
};
|
|
744
713
|
|
|
@@ -911,7 +880,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
911
880
|
}
|
|
912
881
|
};
|
|
913
882
|
|
|
914
|
-
const startPopup = (): void => {
|
|
883
|
+
const startPopup = async (): Promise<void> => {
|
|
915
884
|
// Synchronous popup open inside the click's call stack.
|
|
916
885
|
relaySession?.cancel();
|
|
917
886
|
clearRemint();
|
|
@@ -928,8 +897,12 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
928
897
|
popupBusy = true;
|
|
929
898
|
renderPopupWaiting();
|
|
930
899
|
|
|
900
|
+
// `runPopup(...)` is invoked synchronously (its `window.open` runs before the
|
|
901
|
+
// first `await`), preserving the user-gesture popup open. Awaiting it inside the
|
|
902
|
+
// try means both a synchronous throw (a custom transport) and an async rejection
|
|
903
|
+
// are handled here — no unhandled promise sits inside the try (Sonar S4822).
|
|
931
904
|
try {
|
|
932
|
-
deps.runPopup({
|
|
905
|
+
const result = await deps.runPopup({
|
|
933
906
|
walletUrl,
|
|
934
907
|
permissionRequests : options.permissionRequests,
|
|
935
908
|
appName,
|
|
@@ -937,20 +910,17 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
937
910
|
timeoutMs : options.timeout,
|
|
938
911
|
delegatePortableDid : options.delegatePortableDid,
|
|
939
912
|
requestType : options.mode,
|
|
940
|
-
})
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
}
|
|
948
|
-
})
|
|
949
|
-
.catch(renderPopupFailure)
|
|
950
|
-
.finally(() => { popupBusy = false; });
|
|
913
|
+
});
|
|
914
|
+
if (settled) { return; }
|
|
915
|
+
if (result === undefined) {
|
|
916
|
+
renderDenied();
|
|
917
|
+
} else {
|
|
918
|
+
succeed(result);
|
|
919
|
+
}
|
|
951
920
|
} catch (error) {
|
|
952
|
-
popupBusy = false;
|
|
953
921
|
renderPopupFailure(error);
|
|
922
|
+
} finally {
|
|
923
|
+
popupBusy = false;
|
|
954
924
|
}
|
|
955
925
|
};
|
|
956
926
|
|
|
@@ -1044,7 +1014,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
1044
1014
|
|
|
1045
1015
|
// The panel grid holds only the wallets NOT already visible in the
|
|
1046
1016
|
// row, so expanding never shows the same wallet twice.
|
|
1047
|
-
const gridWallets = wallets.filter((wallet) => !rowWallets
|
|
1017
|
+
const gridWallets = wallets.filter((wallet) => !walletInCatalog(rowWallets, wallet.url));
|
|
1048
1018
|
|
|
1049
1019
|
const more = document.createElement('button');
|
|
1050
1020
|
more.className = 'row-tile more-tile';
|
|
@@ -1340,6 +1310,77 @@ function buildHeader(titleText: string, appIcon: string | undefined, onCancel: (
|
|
|
1340
1310
|
return header;
|
|
1341
1311
|
}
|
|
1342
1312
|
|
|
1313
|
+
/**
|
|
1314
|
+
* Builds the PIN-entry boxes, wires their input/keydown/paste behavior, and
|
|
1315
|
+
* returns the input elements in order. Module-level — `renderPin()`'s own
|
|
1316
|
+
* `new Promise` executor already nests two levels deep inside
|
|
1317
|
+
* `runConnectModal()`, so a per-input closure defined there would sit too
|
|
1318
|
+
* deep for Sonar's S2004 nesting-depth check.
|
|
1319
|
+
*
|
|
1320
|
+
* `getPinResolve`/`clearPinResolve` proxy the `pinResolve` state kept in
|
|
1321
|
+
* `runConnectModal()`'s closure: a completed code needs it read once, then
|
|
1322
|
+
* cleared — in that order, before the busy state renders and the pending
|
|
1323
|
+
* promise resolves — exactly as it was inline.
|
|
1324
|
+
*/
|
|
1325
|
+
function buildPinInputs(
|
|
1326
|
+
boxes: HTMLDivElement,
|
|
1327
|
+
getPinResolve: () => ((pin: string) => void) | undefined,
|
|
1328
|
+
clearPinResolve: () => void,
|
|
1329
|
+
renderBusy: (message: string) => void,
|
|
1330
|
+
): HTMLInputElement[] {
|
|
1331
|
+
const inputs: HTMLInputElement[] = [];
|
|
1332
|
+
for (let i = 0; i < PIN_LENGTH; i++) {
|
|
1333
|
+
const input = document.createElement('input');
|
|
1334
|
+
input.className = 'pin-input';
|
|
1335
|
+
input.type = 'text';
|
|
1336
|
+
input.inputMode = 'numeric';
|
|
1337
|
+
input.maxLength = 1;
|
|
1338
|
+
input.setAttribute('aria-label', `Code digit ${i + 1}`);
|
|
1339
|
+
inputs.push(input);
|
|
1340
|
+
boxes.appendChild(input);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const submitIfComplete = (): void => {
|
|
1344
|
+
const code = inputs.map((i) => i.value).join('');
|
|
1345
|
+
const pinResolve = getPinResolve();
|
|
1346
|
+
if (code.length === PIN_LENGTH && pinResolve !== undefined) {
|
|
1347
|
+
const resolveOnce = pinResolve;
|
|
1348
|
+
clearPinResolve();
|
|
1349
|
+
renderBusy('Checking the code…');
|
|
1350
|
+
resolveOnce(code);
|
|
1351
|
+
}
|
|
1352
|
+
};
|
|
1353
|
+
|
|
1354
|
+
inputs.forEach((input, index) => {
|
|
1355
|
+
input.addEventListener('input', () => {
|
|
1356
|
+
input.value = input.value.replace(/\D/g, '').slice(0, 1);
|
|
1357
|
+
if (input.value !== '' && index + 1 < inputs.length) {
|
|
1358
|
+
inputs[index + 1].focus();
|
|
1359
|
+
}
|
|
1360
|
+
submitIfComplete();
|
|
1361
|
+
});
|
|
1362
|
+
input.addEventListener('keydown', (event) => {
|
|
1363
|
+
if (event.key === 'Backspace' && input.value === '' && index > 0) {
|
|
1364
|
+
inputs[index - 1].focus();
|
|
1365
|
+
}
|
|
1366
|
+
});
|
|
1367
|
+
input.addEventListener('paste', (event) => {
|
|
1368
|
+
const digits = (event.clipboardData?.getData('text') ?? '').replace(/\D/g, '').slice(0, PIN_LENGTH);
|
|
1369
|
+
if (digits.length === 0) { return; }
|
|
1370
|
+
event.preventDefault();
|
|
1371
|
+
digits.split('').forEach((digit, digitIndex) => {
|
|
1372
|
+
if (inputs[digitIndex] !== undefined) {
|
|
1373
|
+
inputs[digitIndex].value = digit;
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
inputs[Math.min(digits.length, PIN_LENGTH) - 1]?.focus();
|
|
1377
|
+
submitIfComplete();
|
|
1378
|
+
});
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
return inputs;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1343
1384
|
function trapFocus(shadow: ShadowRoot, event: KeyboardEvent): void {
|
|
1344
1385
|
const focusables = Array.from(
|
|
1345
1386
|
shadow.querySelectorAll<HTMLElement>('button, input, a[href], [tabindex]:not([tabindex="-1"])'),
|
package/src/ui/qr.ts
CHANGED
|
@@ -59,21 +59,58 @@ function dataCodewords(version: number): number {
|
|
|
59
59
|
export function encodeQr(text: string, forcedMask?: number): QrCode {
|
|
60
60
|
const data = new TextEncoder().encode(text);
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
const version = selectVersion(data.length);
|
|
63
|
+
if (version === 0) {
|
|
64
|
+
throw new Error(`[@enbox/browser] QR payload too long (${data.length} bytes exceeds version ${MAX_VERSION}-M capacity).`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const codewords = buildDataCodewords(data, version);
|
|
68
|
+
|
|
69
|
+
// ── Reed-Solomon blocks + interleave. ──
|
|
70
|
+
const allCodewords = buildInterleavedCodewords(codewords, version);
|
|
71
|
+
|
|
72
|
+
// ── Module placement. ──
|
|
73
|
+
const size = 17 + 4 * version;
|
|
74
|
+
const modules: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
|
|
75
|
+
const isFunction: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
|
|
76
|
+
|
|
77
|
+
drawFunctionPatterns(modules, isFunction, version);
|
|
78
|
+
drawCodewords(modules, isFunction, allCodewords);
|
|
79
|
+
|
|
80
|
+
// ── Mask selection: apply each mask, score, keep the best. ──
|
|
81
|
+
let mask = forcedMask ?? -1;
|
|
82
|
+
if (mask === -1) {
|
|
83
|
+
mask = selectBestMask(modules, isFunction);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
applyMask(modules, isFunction, mask);
|
|
87
|
+
drawFormatBits(modules, isFunction, mask);
|
|
88
|
+
|
|
89
|
+
return { size, modules, version, mask };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Smallest version (1–{@link MAX_VERSION}) whose level-M byte-mode capacity
|
|
94
|
+
* fits a `dataLength`-byte payload plus its mode/char-count header; `0` if
|
|
95
|
+
* none fits.
|
|
96
|
+
*/
|
|
97
|
+
function selectVersion(dataLength: number): number {
|
|
64
98
|
for (let candidate = 1; candidate <= MAX_VERSION; candidate++) {
|
|
65
99
|
const capacityBits = dataCodewords(candidate) * 8;
|
|
66
100
|
const headerBits = 4 + (candidate <= 9 ? 8 : 16);
|
|
67
|
-
if (headerBits +
|
|
68
|
-
|
|
69
|
-
break;
|
|
101
|
+
if (headerBits + dataLength * 8 <= capacityBits) {
|
|
102
|
+
return candidate;
|
|
70
103
|
}
|
|
71
104
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
75
107
|
|
|
76
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Builds the packed data codewords for `data` at `version`: mode indicator,
|
|
110
|
+
* char count, byte data, terminator, byte-alignment padding, and the
|
|
111
|
+
* alternating pad-byte fill up to the version's level-M data capacity.
|
|
112
|
+
*/
|
|
113
|
+
function buildDataCodewords(data: Uint8Array, version: number): Uint8Array {
|
|
77
114
|
const bits: number[] = [];
|
|
78
115
|
const pushBits = (value: number, length: number): void => {
|
|
79
116
|
for (let i = length - 1; i >= 0; i--) {
|
|
@@ -92,46 +129,34 @@ export function encodeQr(text: string, forcedMask?: number): QrCode {
|
|
|
92
129
|
pushBits(0, (8 - (bits.length % 8)) % 8); // byte align
|
|
93
130
|
|
|
94
131
|
const padBytes = [0xEC, 0x11];
|
|
95
|
-
|
|
96
|
-
|
|
132
|
+
let padIndex = 0;
|
|
133
|
+
while (bits.length < capacityBits) {
|
|
134
|
+
pushBits(padBytes[padIndex], 8);
|
|
135
|
+
padIndex ^= 1; // alternate the two pad bytes
|
|
97
136
|
}
|
|
98
137
|
|
|
99
138
|
const codewords = new Uint8Array(capacityBits / 8);
|
|
100
139
|
for (let i = 0; i < bits.length; i++) {
|
|
101
140
|
codewords[i >> 3] |= bits[i] << (7 - (i & 7));
|
|
102
141
|
}
|
|
142
|
+
return codewords;
|
|
143
|
+
}
|
|
103
144
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// ── Mask selection: apply each mask, score, keep the best. ──
|
|
116
|
-
let mask = forcedMask ?? -1;
|
|
117
|
-
if (mask === -1) {
|
|
118
|
-
let bestPenalty = Infinity;
|
|
119
|
-
for (let candidate = 0; candidate < 8; candidate++) {
|
|
120
|
-
applyMask(modules, isFunction, candidate);
|
|
121
|
-
drawFormatBits(modules, isFunction, candidate);
|
|
122
|
-
const penalty = computePenalty(modules);
|
|
123
|
-
if (penalty < bestPenalty) {
|
|
124
|
-
bestPenalty = penalty;
|
|
125
|
-
mask = candidate;
|
|
126
|
-
}
|
|
127
|
-
applyMask(modules, isFunction, candidate); // undo (XOR is involutive)
|
|
145
|
+
/** Applies each of the 8 mask patterns, scores it, and returns the lowest-penalty mask index. */
|
|
146
|
+
function selectBestMask(modules: boolean[][], isFunction: boolean[][]): number {
|
|
147
|
+
let bestMask = 0;
|
|
148
|
+
let bestPenalty = Infinity;
|
|
149
|
+
for (let candidate = 0; candidate < 8; candidate++) {
|
|
150
|
+
applyMask(modules, isFunction, candidate);
|
|
151
|
+
drawFormatBits(modules, isFunction, candidate);
|
|
152
|
+
const penalty = computePenalty(modules);
|
|
153
|
+
if (penalty < bestPenalty) {
|
|
154
|
+
bestPenalty = penalty;
|
|
155
|
+
bestMask = candidate;
|
|
128
156
|
}
|
|
157
|
+
applyMask(modules, isFunction, candidate); // undo (XOR is involutive)
|
|
129
158
|
}
|
|
130
|
-
|
|
131
|
-
applyMask(modules, isFunction, mask);
|
|
132
|
-
drawFormatBits(modules, isFunction, mask);
|
|
133
|
-
|
|
134
|
-
return { size, modules, version, mask };
|
|
159
|
+
return bestMask;
|
|
135
160
|
}
|
|
136
161
|
|
|
137
162
|
/** Splits data codewords into level-M blocks, computes ECC, interleaves. */
|
|
@@ -357,21 +382,46 @@ function drawCodewords(modules: boolean[][], isFunction: boolean[][], codewords:
|
|
|
357
382
|
let bitIndex = 0;
|
|
358
383
|
|
|
359
384
|
for (let right = size - 1; right >= 1; right -= 2) {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
385
|
+
// Visit columns in right-to-left pairs, shifting one column left of the
|
|
386
|
+
// vertical timing column (index 6) so it is skipped — without mutating the
|
|
387
|
+
// loop counter (Sonar S2310). `size` is always odd, so `right` is always
|
|
388
|
+
// even and `right - 1` maps 6→5, 4→3, 2→1, matching the original traversal.
|
|
389
|
+
const rightCol = right <= 6 ? right - 1 : right;
|
|
390
|
+
bitIndex = placeColumnPairBits(modules, isFunction, codewords, rightCol, size, bitIndex);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Places codeword bits into the zig-zag column pair (`rightCol`,
|
|
396
|
+
* `rightCol - 1`) top-to-bottom or bottom-to-top per {@link zigZagCoordinate},
|
|
397
|
+
* starting at `bitIndex`; returns the updated bit index.
|
|
398
|
+
*/
|
|
399
|
+
function placeColumnPairBits(
|
|
400
|
+
modules: boolean[][],
|
|
401
|
+
isFunction: boolean[][],
|
|
402
|
+
codewords: Uint8Array,
|
|
403
|
+
rightCol: number,
|
|
404
|
+
size: number,
|
|
405
|
+
bitIndex: number,
|
|
406
|
+
): number {
|
|
407
|
+
for (let vertical = 0; vertical < size; vertical++) {
|
|
408
|
+
for (let j = 0; j < 2; j++) {
|
|
409
|
+
const { x, y } = zigZagCoordinate(rightCol, vertical, j, size);
|
|
410
|
+
if (!isFunction[y][x] && bitIndex < codewords.length * 8) {
|
|
411
|
+
modules[y][x] = ((codewords[bitIndex >> 3] >>> (7 - (bitIndex & 7))) & 1) !== 0;
|
|
412
|
+
bitIndex++;
|
|
372
413
|
}
|
|
373
414
|
}
|
|
374
415
|
}
|
|
416
|
+
return bitIndex;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Module coordinate for column-pair `rightCol`, row `vertical`, sub-column `j` (0 = right, 1 = left). */
|
|
420
|
+
function zigZagCoordinate(rightCol: number, vertical: number, j: number, size: number): { x: number; y: number } {
|
|
421
|
+
const x = rightCol - j;
|
|
422
|
+
const upward = ((rightCol + 1) & 2) === 0;
|
|
423
|
+
const y = upward ? size - 1 - vertical : vertical;
|
|
424
|
+
return { x, y };
|
|
375
425
|
}
|
|
376
426
|
|
|
377
427
|
/** XORs the mask pattern over non-function modules (involutive). */
|
|
@@ -402,11 +452,10 @@ function applyMask(modules: boolean[][], isFunction: boolean[][], mask: number):
|
|
|
402
452
|
|
|
403
453
|
// ── Mask penalty scoring (rules N1–N4) ──────────────────────────
|
|
404
454
|
|
|
405
|
-
|
|
406
|
-
|
|
455
|
+
/** N1: runs of ≥5 same-colour modules in a row/column. */
|
|
456
|
+
function penaltyAdjacentRuns(modules: boolean[][], size: number): number {
|
|
407
457
|
let penalty = 0;
|
|
408
458
|
|
|
409
|
-
// N1: runs of ≥5 same-colour modules in a row/column.
|
|
410
459
|
for (let y = 0; y < size; y++) {
|
|
411
460
|
let runColor = false;
|
|
412
461
|
let runLength = 0;
|
|
@@ -442,7 +491,13 @@ function computePenalty(modules: boolean[][]): number {
|
|
|
442
491
|
}
|
|
443
492
|
}
|
|
444
493
|
|
|
445
|
-
|
|
494
|
+
return penalty;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** N2: 2×2 blocks of the same colour. */
|
|
498
|
+
function penaltyBlocks(modules: boolean[][], size: number): number {
|
|
499
|
+
let penalty = 0;
|
|
500
|
+
|
|
446
501
|
for (let y = 0; y < size - 1; y++) {
|
|
447
502
|
for (let x = 0; x < size - 1; x++) {
|
|
448
503
|
const c = modules[y][x];
|
|
@@ -452,17 +507,24 @@ function computePenalty(modules: boolean[][]): number {
|
|
|
452
507
|
}
|
|
453
508
|
}
|
|
454
509
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
510
|
+
return penalty;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Checks `line` for a 1:1:3:1:1 finder-like core starting at `start`, scoring a 4-module light flank on either side. */
|
|
514
|
+
function hasFinderPattern(line: boolean[], start: number): number {
|
|
515
|
+
const core = [true, false, true, true, true, false, true];
|
|
516
|
+
const matchesCore = core.every((value, index) => line[start + index] === value);
|
|
517
|
+
if (!matchesCore) {
|
|
518
|
+
return 0;
|
|
519
|
+
}
|
|
520
|
+
const lightBefore = start >= 4 && line.slice(start - 4, start).every((v) => !v);
|
|
521
|
+
const lightAfter = start + 11 <= line.length && line.slice(start + 7, start + 11).every((v) => !v);
|
|
522
|
+
return (lightBefore ? 40 : 0) + (lightAfter ? 40 : 0);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** N3: finder-like 1:1:3:1:1 patterns with 4-module light flank. */
|
|
526
|
+
function penaltyFinderPatterns(modules: boolean[][], size: number): number {
|
|
527
|
+
let penalty = 0;
|
|
466
528
|
|
|
467
529
|
for (let y = 0; y < size; y++) {
|
|
468
530
|
const row = modules[y];
|
|
@@ -473,7 +535,11 @@ function computePenalty(modules: boolean[][]): number {
|
|
|
473
535
|
}
|
|
474
536
|
}
|
|
475
537
|
|
|
476
|
-
|
|
538
|
+
return penalty;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** N4: dark-module proportion deviation from 50%, in 5% steps. */
|
|
542
|
+
function penaltyDarkRatio(modules: boolean[][], size: number): number {
|
|
477
543
|
let dark = 0;
|
|
478
544
|
for (const row of modules) {
|
|
479
545
|
for (const module of row) {
|
|
@@ -484,7 +550,17 @@ function computePenalty(modules: boolean[][]): number {
|
|
|
484
550
|
}
|
|
485
551
|
const total = size * size;
|
|
486
552
|
const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
|
|
487
|
-
|
|
553
|
+
return k * 10;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function computePenalty(modules: boolean[][]): number {
|
|
557
|
+
const size = modules.length;
|
|
558
|
+
let penalty = 0;
|
|
559
|
+
|
|
560
|
+
penalty += penaltyAdjacentRuns(modules, size);
|
|
561
|
+
penalty += penaltyBlocks(modules, size);
|
|
562
|
+
penalty += penaltyFinderPatterns(modules, size);
|
|
563
|
+
penalty += penaltyDarkRatio(modules, size);
|
|
488
564
|
|
|
489
565
|
return penalty;
|
|
490
566
|
}
|