Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,21 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added
- #### Icon
- `registerIcon` and `registerIconFromText` now accept a `RegisterIconOptions` object as their third argument in addition to the existing plain collection string. Setting `stripMeta: true` removes `<title>` and `<desc>` elements from the stored SVG, preventing the browser from displaying a native tooltip on hover. The title text is still captured and exposed as the `aria-label` of the host `<igc-icon>` element. Any `aria-labelledby` / `aria-describedby` references on the root `<svg>` that pointed to the stripped elements' IDs are cleaned up automatically. [#1822](https://github.com/IgniteUI/igniteui-webcomponents/issues/1822)
- #### QR Code
- Added the new `igc-qr-code` component. Renders a scannable QR code as an SVG based on the provided `value`, automatically encoding it in numeric, alphanumeric, or byte mode. [#2308](https://github.com/IgniteUI/igniteui-webcomponents/pull/2308)
- Supports an explicit `version` (1-40) and `error-level` (`L`/`M`/`Q`/`H`), or automatically picks the smallest version and level that fit the data.
- `size` and `margin` control the rendered pixel dimensions and the quiet zone (in modules) around the code.
- Optional branding logo via `logo-src`, with `logo-size` and `logo-margin` controlling how much of the code's safe, scannable area it covers; the error correction level is raised automatically to accommodate it unless explicitly set.
- `dot-style` and `square-style` customize the shape of the data modules and finder-pattern corners (`square`, `circle`, `rounded`).
- Themable via the `--ig-qr-code-background`, `--ig-qr-code-dark-color`, `--ig-qr-code-corner-square-color`, and `--ig-qr-code-corner-dot-color` CSS custom properties, and exposes `background`, `dots`, `corner-square`, and `corner-dot` CSS parts.
- #### Virtual Scroll
- Added the new `igc-virtual-scroll` component. It efficiently renders large or unbounded lists by only rendering the items currently within the viewport, plus a configurable `overScan`. [#2222](https://github.com/IgniteUI/igniteui-webcomponents/pull/2222)
- Supports both `vertical` and `horizontal` orientation, including RTL layouts.
- Item sizes may be fixed, estimated via `estimatedItemSize`, or fully variable each rendered item is measured automatically and its estimate is corrected on the fly, without requiring any manual intervention.
- Item sizes may be fixed, estimated via `estimatedItemSize`, or fully variable - each rendered item is measured automatically and its estimate is corrected on the fly, without requiring any manual intervention.
- Added `scrollToIndex()` for programmatically scrolling to a given item, with configurable alignment (`block`/`inline`) and scroll `behavior` (`auto`/`smooth`). The target offset is automatically corrected as previously unmeasured items around it get measured, so the requested item ends up precisely aligned even when it wasn't previously rendered.
- Added the `igcDataRequest` event, emitted when the scroll position approaches the end of the currently loaded data, to support infinite-scroll / remote data loading scenarios.
- Added the `igcStateChange` event, emitted after each render pass with a snapshot of the current virtual window (`startIndex`, `endIndex`, `viewportSize`, `totalSize`).
- Added the `layoutComplete` property a promise that resolves once the current render pass, and any follow-up renders triggered by item measurement, have fully settled.
- Added the `layoutComplete` property - a promise that resolves once the current render pass, and any follow-up renders triggered by item measurement, have fully settled.
- Transparently degrades past the maximum scroll coordinate supported by the browser, so lists far larger than the DOM would normally allow keep scrolling and rendering correctly.

### Changed
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
"husky": "^9.1.7",
"ig-typedoc-theme": "^7.0.1",
"igniteui-i18n-resources": "^1.0.5",
"igniteui-theming": "^27.4.0",
"igniteui-theming": "^28.0.0-beta.1",
"keep-a-changelog": "^3.0.5",
"lint-staged": "^17.2.0",
"lit-analyzer": "^2.0.3",
Expand Down
4 changes: 4 additions & 0 deletions src/components/common/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,10 @@ function fillPool(bytes: number): void {
poolOffset += bytes;
}

/**
* Generates a unique string ID of the specified size using a URL-friendly alphabet.
* The default size is 21 characters, which provides a very low probability of collisions.
*/
export function nanoid(size = 21): string {
const bytes = size | 0;
fillPool(bytes);
Expand Down
229 changes: 229 additions & 0 deletions src/components/qr-code/model/encode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import type { QrEncodingMode, QrErrorCorrectionLevel } from '../types.js';
import { getDataCodewordsCount, interleaveBlocks } from './error-correction.js';

const EC_LEVEL_INDEX = { L: 0, M: 1, Q: 2, H: 3 } as const;
const ALPHANUMERIC_MAP = new Map<string, number>(
[...'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'].map((char, index) => [
char,
index,
])
);
const PAD_BYTES = [0xec, 0x11];
const TEXT_ENCODER = new TextEncoder();

function getAlphanumericValue(char: string): number {
return ALPHANUMERIC_MAP.get(char) ?? -1;
}

function isNumeric(str: string): boolean {
return /^\d+$/.test(str);
}

function isAlphanumeric(str: string): boolean {
for (const char of str) {
if (!ALPHANUMERIC_MAP.has(char)) {
return false;
}
}
return true;
}

function detectEncodingMode(data: string): QrEncodingMode {
if (isNumeric(data)) return 'numeric';
if (isAlphanumeric(data)) return 'alphanumeric';
return 'byte';
}

function getCharacterCountBits(mode: QrEncodingMode, version: number): number {
switch (mode) {
case 'numeric':
return version < 10 ? 10 : version < 27 ? 12 : 14;
case 'alphanumeric':
return version < 10 ? 9 : version < 27 ? 11 : 13;
case 'byte':
return version < 10 ? 8 : version < 27 ? 16 : 16;
default:
throw new Error(`Unsupported encoding mode: ${mode}`);
}
}

const MODE_INDICATORS: Record<QrEncodingMode, number> = {
numeric: 0b0001,
alphanumeric: 0b0010,
byte: 0b0100,
};

function pushBits(bits: number[], value: number, length: number): void {
for (let i = length - 1; i >= 0; i--) {
bits.push((value >> i) & 1);
}
}

function encodeData(
data: string,
mode: QrEncodingMode,
version: number
): number[] {
const bits: number[] = [];
const byteEncoded = mode === 'byte' ? TEXT_ENCODER.encode(data) : null;

pushBits(bits, MODE_INDICATORS[mode], 4);

const charCount = byteEncoded ? byteEncoded.length : data.length;
pushBits(bits, charCount, getCharacterCountBits(mode, version));

switch (mode) {
case 'numeric':
for (let i = 0; i < data.length; i += 3) {
const chunk = data.slice(i, i + 3);
const value = Number.parseInt(chunk, 10);

if (chunk.length === 3) pushBits(bits, value, 10);
else if (chunk.length === 2) pushBits(bits, value, 7);
else pushBits(bits, value, 4);
}
break;
case 'alphanumeric':
for (let i = 0; i < data.length; i += 2) {
if (i + 1 < data.length) {
const value =
getAlphanumericValue(data[i]) * 45 +
getAlphanumericValue(data[i + 1]);
pushBits(bits, value, 11);
} else {
pushBits(bits, getAlphanumericValue(data[i]), 6);
}
}
break;
default:
for (const byte of byteEncoded!) {
pushBits(bits, byte, 8);
}
break;
}

return bits;
}

function bitsToBytes(bits: number[]): number[] {
const bytes: number[] = [];
for (let i = 0; i < bits.length; i += 8) {
let byte = 0;
for (let j = 0; j < 8 && i + j < bits.length; j++) {
byte = (byte << 1) | bits[i + j];
}
bytes.push(byte);
}
return bytes;
}

function padData(data: number[], totalBytes: number): number[] {
const result = data.slice();

if (result.length > totalBytes) {
throw new Error(
'Data exceeds maximum capacity for this version and error correction level'
);
}

let padIndex = 0;
while (result.length < totalBytes) {
result.push(PAD_BYTES[padIndex % 2]);
padIndex++;
}
return result;
}

/** Result produced by `encodeQR`. */
export type EncodeResult = {
/** Interleaved data + ECC codewords ready for matrix placement. */
codewords: number[];
/** Encoding mode that was applied to the input string. */
mode: QrEncodingMode;
/** QR version (1–40) used for this code. */
version: number;
/** Numeric index of the error correction level (L=0, M=1, Q=2, H=3). */
ecLevelIndex: number;
};

/**
* Encodes a string into QR codewords (data + error correction), selecting the
* smallest version that fits unless `requestedVersion` is specified.
*
* @throws When `data` is empty, the requested version is out of range, or the
* data exceeds the capacity of the requested version.
*/
export function encodeQR(
data: string,
ecLevel: QrErrorCorrectionLevel = 'M',
requestedVersion?: number
): EncodeResult {
if (data.length === 0) {
throw new Error('Data cannot be empty');
}

const ecIndex = EC_LEVEL_INDEX[ecLevel];
const mode = detectEncodingMode(data);

let version = 1;
let bits: number[];

if (requestedVersion != null) {
if (requestedVersion < 1 || requestedVersion > 40) {
throw new Error('Requested version must be between 1 and 40');
}
version = requestedVersion;
bits = encodeData(data, mode, version);
if (
Math.ceil((bits.length + 4) / 8) > getDataCodewordsCount(version, ecIndex)
) {
throw new Error(
`Data too long for version ${version} and error correction level ${ecLevel}`
);
}
} else {
bits = [];
for (let v = 1; v <= 40; v++) {
const candidateBits = encodeData(data, mode, v);
if (
Math.ceil((candidateBits.length + 4) / 8) <=
getDataCodewordsCount(v, ecIndex)
) {
version = v;
bits = candidateBits;
break;
}
}

// encodeData() always produces a non-empty bit sequence, so an empty
// `bits` here means no version (1-40) could fit the data.
if (bits.length === 0) {
throw new Error(
`Data too long to fit in any QR version (1-40) at error correction level '${ecLevel}'`
);
}
}

const capacity = getDataCodewordsCount(version, ecIndex);

const maxBits = capacity * 8;
const terminatorLength = Math.min(4, maxBits - bits.length);
for (let i = 0; i < terminatorLength; i++) {
bits.push(0);
}

while (bits.length % 8 !== 0) {
bits.push(0);
}

const dataBytes = bitsToBytes(bits);
const paddedData = padData(dataBytes, capacity);
const codewords = interleaveBlocks(paddedData, version, ecIndex);

return {
codewords,
mode,
version,
ecLevelIndex: ecIndex,
};
}
Loading