diff --git a/example/app.json b/example/app.json index 32e5a2e84..9e3fa4016 100644 --- a/example/app.json +++ b/example/app.json @@ -11,6 +11,7 @@ "pages/loading/skyline/loading", "pages/progress/progress", "pages/progress/skyline/progress", + "pages/qrcode/qrcode", "pages/cascader/cascader", "pages/cell/cell", "pages/cell/skyline/cell", diff --git a/example/pages/home/data/display.ts b/example/pages/home/data/display.ts index c9b4c5a4f..42e91a8a2 100644 --- a/example/pages/home/data/display.ts +++ b/example/pages/home/data/display.ts @@ -54,6 +54,11 @@ const display = { name: 'Progress', label: '进度条', }, + { + name: 'QRCode', + label: '二维码', + path: '/pages/qrcode/qrcode', + }, { name: 'Result', label: '结果', diff --git a/example/project.config.json b/example/project.config.json index c76046daf..2cc004050 100644 --- a/example/project.config.json +++ b/example/project.config.json @@ -26,7 +26,7 @@ "bigPackageSizeSupport": true }, "compileType": "miniprogram", - "libVersion": "3.6.3", + "libVersion": "3.8.11", "appid": "wx6f3e38f61d138c04", "projectname": "TDesign", "debugOptions": { @@ -256,6 +256,12 @@ "query": "", "scene": null }, + { + "name": "qrcode", + "pathName": "pages/qrcode/qrcode", + "query": "", + "scene": null + }, { "name": "overlay", "pathName": "pages/overlay/overlay", diff --git a/site/site.config.mjs b/site/site.config.mjs index 388591973..c3fe87d55 100644 --- a/site/site.config.mjs +++ b/site/site.config.mjs @@ -429,6 +429,14 @@ export const docs = [ path: '/miniprogram/components/progress', component: () => import('@/progress/README.md'), }, + { + title: 'QRCode 二维码', + titleEn: 'QRCode', + name: 'qrcode', + meta: { docType: 'data' }, + path: '/miniprogram/components/qrcode', + component: () => import('@/qrcode/README.md'), + }, { title: 'Result 结果', titleEn: 'Result', diff --git a/src/_common b/src/_common index 23ecc659a..5b2034774 160000 --- a/src/_common +++ b/src/_common @@ -1 +1 @@ -Subproject commit 23ecc659a8b58a619651c8d5f600f9c02a5699ca +Subproject commit 5b2034774e931ebea337e14fcf11274e92d606f8 diff --git a/src/common/shared/qrcode/qrcodegen.ts b/src/common/shared/qrcode/qrcodegen.ts new file mode 100644 index 000000000..046df621a --- /dev/null +++ b/src/common/shared/qrcode/qrcodegen.ts @@ -0,0 +1,1051 @@ +/* eslint-disable */ +// Copyright (c) Project Nayuki. (MIT License) +// https://www.nayuki.io/page/qr-code-generator-library + +// Modification with code reorder and prettier + +// -------------------------------------------- + +// Appends the given number of low-order bits of the given value +// to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len. +function appendBits(val: number, len: number, bb: number[]): void { + if (len < 0 || len > 31 || val >>> len !== 0) { + throw new RangeError('Value out of range'); + } + for ( + let i = len - 1; + i >= 0; + i-- // Append bit by bit + ) { + bb.push((val >>> i) & 1); + } +} + +// Returns true iff the i'th bit of x is set to 1. +function getBit(x: number, i: number): boolean { + return ((x >>> i) & 1) !== 0; +} + +// Throws an exception if the given condition is false. +function assert(cond: boolean): void { + if (!cond) { + throw new Error('Assertion error'); + } +} + +/* ---- Public helper enumeration ----*/ +/* + * Describes how a segment's data bits are numbererpreted. Immutable. + */ +export class Mode { + /* -- Constants --*/ + + public static readonly NUMERIC = new Mode(0x1, [10, 12, 14]); + + public static readonly ALPHANUMERIC = new Mode(0x2, [9, 11, 13]); + + public static readonly BYTE = new Mode(0x4, [8, 16, 16]); + + public static readonly KANJI = new Mode(0x8, [8, 10, 12]); + + public static readonly ECI = new Mode(0x7, [0, 0, 0]); + + /* -- Constructor and fields --*/ + + // The mode indicator bits, which is a unumber4 value (range 0 to 15). + public modeBits: number; + + // Number of character count bits for three different version ranges. + private numBitsCharCount: [number, number, number]; + + private constructor(modeBits: number, numBitsCharCount: [number, number, number]) { + this.modeBits = modeBits; + this.numBitsCharCount = numBitsCharCount; + } + + /* -- Method --*/ + + // (Package-private) Returns the bit width of the character count field for a segment in + // this mode in a QR Code at the given version number. The result is in the range [0, 16]. + public numCharCountBits(ver: number): number { + return this.numBitsCharCount[Math.floor((ver + 7) / 17)]; + } +} + +/* ---- Public helper enumeration ----*/ + +/* + * The error correction level in a QR Code symbol. Immutable. + */ +export class Ecc { + /* -- Constants --*/ + + public static readonly LOW = new Ecc(0, 1); // The QR Code can tolerate about 7% erroneous codewords + + public static readonly MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords + + public static readonly QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords + + public static readonly HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords + + /* -- Constructor and fields --*/ + // In the range 0 to 3 (unsigned 2-bit numbereger). + public ordinal: number; + + // (Package-private) In the range 0 to 3 (unsigned 2-bit numbereger). + public formatBits: number; + + private constructor(ordinal: number, formatBits: number) { + this.ordinal = ordinal; + this.formatBits = formatBits; + } +} + +/* + * A segment of character/binary/control data in a QR Code symbol. + * Instances of this class are immutable. + * The mid-level way to create a segment is to take the payload data + * and call a static factory function such as QrSegment.makeNumeric(). + * The low-level way to create a segment is to custom-make the bit buffer + * and call the QrSegment() constructor with appropriate values. + * This segment class imposes no length restrictions, but QR Codes have restrictions. + * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. + * Any segment longer than this is meaningless for the purpose of generating QR Codes. + */ +export class QrSegment { + /* -- Static factory functions (mid level) --*/ + + // Returns a segment representing the given binary data encoded in + // byte mode. All input byte arrays are acceptable. Any text string + // can be converted to UTF-8 bytes and encoded as a byte mode segment. + public static makeBytes(data: Readonly): QrSegment { + const bb: number[] = []; + for (const b of data) { + appendBits(b, 8, bb); + } + return new QrSegment(Mode.BYTE, data.length, bb); + } + + // Returns a segment representing the given string of decimal digits encoded in numeric mode. + public static makeNumeric(digits: string): QrSegment { + if (!QrSegment.isNumeric(digits)) { + throw new RangeError('String contains non-numeric characters'); + } + const bb: number[] = []; + for (let i = 0; i < digits.length; ) { + // Consume up to 3 digits per iteration + const n: number = Math.min(digits.length - i, 3); + appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb); + i += n; + } + return new QrSegment(Mode.NUMERIC, digits.length, bb); + } + + // Returns a segment representing the given text string encoded in alphanumeric mode. + // The characters allowed are: 0 to 9, A to Z (uppercase only), space, + // dollar, percent, asterisk, plus, hyphen, period, slash, colon. + public static makeAlphanumeric(text: string): QrSegment { + if (!QrSegment.isAlphanumeric(text)) { + throw new RangeError('String contains unencodable characters in alphanumeric mode'); + } + const bb: number[] = []; + let i: number; + for (i = 0; i + 2 <= text.length; i += 2) { + // Process groups of 2 + let temp: number = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45; + temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); + appendBits(temp, 11, bb); + } + if (i < text.length) { + // 1 character remaining + appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb); + } + return new QrSegment(Mode.ALPHANUMERIC, text.length, bb); + } + + // Returns a new mutable list of zero or more segments to represent the given Unicode text string. + // The result may use various segment modes and switch modes to optimize the length of the bit stream. + public static makeSegments(text: string): QrSegment[] { + // Select the most efficient segment encoding automatically + if (text === '') { + return []; + } + if (QrSegment.isNumeric(text)) { + return [QrSegment.makeNumeric(text)]; + } + if (QrSegment.isAlphanumeric(text)) { + return [QrSegment.makeAlphanumeric(text)]; + } + return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))]; + } + + // Returns a segment representing an Extended Channel Interpretation + // (ECI) designator with the given assignment value. + public static makeEci(assignVal: number): QrSegment { + const bb: number[] = []; + if (assignVal < 0) { + throw new RangeError('ECI assignment value out of range'); + } else if (assignVal < 1 << 7) { + appendBits(assignVal, 8, bb); + } else if (assignVal < 1 << 14) { + appendBits(0b10, 2, bb); + appendBits(assignVal, 14, bb); + } else if (assignVal < 1000000) { + appendBits(0b110, 3, bb); + appendBits(assignVal, 21, bb); + } else { + throw new RangeError('ECI assignment value out of range'); + } + return new QrSegment(Mode.ECI, 0, bb); + } + + // Tests whether the given string can be encoded as a segment in numeric mode. + // A string is encodable iff each character is in the range 0 to 9. + public static isNumeric(text: string): boolean { + return QrSegment.NUMERIC_REGEX.test(text); + } + + // Tests whether the given string can be encoded as a segment in alphanumeric mode. + // A string is encodable iff each character is in the following set: 0 to 9, A to Z + // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. + public static isAlphanumeric(text: string): boolean { + return QrSegment.ALPHANUMERIC_REGEX.test(text); + } + + /* -- Constructor (low level) and fields --*/ + // The mode indicator of this segment. + public mode: Mode; + + // The length of this segment's unencoded data. Measured in characters for + // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. + // Always zero or positive. Not the same as the data's bit length. + public numChars: number; + + // The data bits of this segment. Accessed through getData(). + private bitData: number[]; + + // Creates a new QR Code segment with the given attributes and data. + // The character count (numChars) must agree with the mode and the bit buffer length, + // but the constranumber isn't checked. The given bit buffer is cloned and stored. + public constructor(mode: Mode, numChars: number, bitData: number[]) { + this.mode = mode; + this.numChars = numChars; + this.bitData = bitData; + if (numChars < 0) { + throw new RangeError('Invalid argument'); + } + this.bitData = bitData.slice(); // Make defensive copy + } + + /* -- Methods --*/ + + // Returns a new copy of the data bits of this segment. + public getData(): number[] { + return this.bitData.slice(); // Make defensive copy + } + + // (Package-private) Calculates and returns the number of bits needed to encode the given segments at + // the given version. The result is infinity if a segment has too many characters to fit its length field. + public static getTotalBits(segs: Readonly, version: number): number { + let result: number = 0; + for (const seg of segs) { + const ccbits: number = seg.mode.numCharCountBits(version); + if (seg.numChars >= 1 << ccbits) { + return Infinity; // The segment's length doesn't fit the field's bit width + } + result += 4 + ccbits + seg.bitData.length; + } + return result; + } + + // Returns a new array of bytes representing the given string encoded in UTF-8. + private static toUtf8ByteArray(input: string): number[] { + const str = encodeURI(input); + const result: number[] = []; + for (let i = 0; i < str.length; i++) { + if (str.charAt(i) !== '%') { + result.push(str.charCodeAt(i)); + } else { + result.push(parseInt(str.substring(i + 1, i + 3), 16)); + i += 2; + } + } + return result; + } + + /* -- Constants --*/ + + // Describes precisely all strings that are encodable in numeric mode. + private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/; + + // Describes precisely all strings that are encodable in alphanumeric mode. + private static readonly ALPHANUMERIC_REGEX: RegExp = /^[A-Z0-9 $%*+.\/:-]*$/; + + // The set of all legal characters in alphanumeric mode, + // where each character value maps to the index in the string. + private static readonly ALPHANUMERIC_CHARSET: string = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'; +} + +/* + * A QR Code symbol, which is a type of two-dimension barcode. + * Invented by Denso Wave and described in the ISO/IEC 18004 standard. + * Instances of this class represent an immutable square grid of dark and light cells. + * The class provides static factory functions to create a QR Code from text or binary data. + * The class covers the QR Code Model 2 specification, supporting all versions (sizes) + * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. + * + * Ways to create a QR Code object: + * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary(). + * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments(). + * - Low level: Custom-make the array of data codeword bytes (including + * segment headers and final padding, excluding error correction codewords), + * supply the appropriate version number, and call the QrCode() constructor. + * (Note that all ways require supplying the desired error correction level.) + */ +export class QrCode { + /* -- Static factory functions (high level) --*/ + + // Returns a QR Code representing the given Unicode text string at the given error correction level. + // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer + // Unicode code ponumbers (not UTF-16 code units) if the low error correction level is used. The smallest possible + // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the + // ecl argument if it can be done without increasing the version. + public static encodeText(text: string, ecl: Ecc): QrCode { + const segs: QrSegment[] = QrSegment.makeSegments(text); + return QrCode.encodeSegments(segs, ecl); + } + + // Returns a QR Code representing the given binary data at the given error correction level. + // This function always encodes using the binary segment mode, not any text mode. The maximum number of + // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. + // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. + public static encodeBinary(data: Readonly, ecl: Ecc): QrCode { + const seg = QrSegment.makeBytes(data); + return QrCode.encodeSegments([seg], ecl); + } + + /* -- Static factory functions (mid level) --*/ + + // Returns a QR Code representing the given segments with the given encoding parameters. + // The smallest possible QR Code version within the given range is automatically + // chosen for the output. Iff boostEcl is true, then the ECC level of the result + // may be higher than the ecl argument if it can be done without increasing the + // version. The mask number is either between 0 to 7 (inclusive) to force that + // mask, or -1 to automatically choose an appropriate mask (which may be slow). + // This function allows the user to create a custom sequence of segments that switches + // between modes (such as alphanumeric and byte) to encode text in less space. + // This is a mid-level API; the high-level API is encodeText() and encodeBinary(). + public static encodeSegments( + segs: Readonly, + oriEcl: Ecc, + minVersion: number = 1, + maxVersion: number = 40, + mask: number = -1, + boostEcl: boolean = true, + ): QrCode { + if ( + !(QrCode.MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= QrCode.MAX_VERSION) || + mask < -1 || + mask > 7 + ) { + throw new RangeError('Invalid value'); + } + + // Find the minimal version number to use + let version: number; + let dataUsedBits: number; + for (version = minVersion; ; version++) { + const dataCapacityBits = QrCode.getNumDataCodewords(version, oriEcl) * 8; // Number of data bits available + const usedBits: number = QrSegment.getTotalBits(segs, version); + if (usedBits <= dataCapacityBits) { + dataUsedBits = usedBits; + break; // This version number is found to be suitable + } + if (version >= maxVersion) { + // All versions in the range could not fit the given data + throw new RangeError('Data too long'); + } + } + let ecl: Ecc = oriEcl; + // Increase the error correction level while the data still fits in the current version number + for (const newEcl of [Ecc.MEDIUM, Ecc.QUARTILE, Ecc.HIGH]) { + // From low to high + if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8) { + ecl = newEcl; + } + } + + // Concatenate all segments to create the data bit string + const bb: number[] = []; + for (const seg of segs) { + appendBits(seg.mode.modeBits, 4, bb); + appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb); + for (const b of seg.getData()) { + bb.push(b); + } + } + assert(bb.length === dataUsedBits); + + // Add terminator and pad up to a byte if applicable + const dataCapacityBits = QrCode.getNumDataCodewords(version, ecl) * 8; + assert(bb.length <= dataCapacityBits); + appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb); + appendBits(0, (8 - (bb.length % 8)) % 8, bb); + assert(bb.length % 8 === 0); + + // Pad with alternating bytes until data capacity is reached + for (let padByte = 0xec; bb.length < dataCapacityBits; padByte ^= 0xec ^ 0x11) { + appendBits(padByte, 8, bb); + } + + // Pack bits numbero bytes in big endian + const dataCodewords: number[] = []; + while (dataCodewords.length * 8 < bb.length) { + dataCodewords.push(0); + } + bb.forEach((b, i) => { + dataCodewords[i >>> 3] |= b << (7 - (i & 7)); + }); + + // Create the QR Code object + return new QrCode(version, ecl, dataCodewords, mask); + } + + /* -- Fields --*/ + + // The width and height of this QR Code, measured in modules, between + // 21 and 177 (inclusive). This is equal to version * 4 + 17. + public readonly size: number; + + // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). + // Even if a QR Code is created with automatic masking requested (mask = -1), + // the resulting object still has a mask value between 0 and 7. + public readonly mask: number; + + // The modules of this QR Code (false = light, true = dark). + // Immutable after constructor finishes. Accessed through getModule(). + private readonly modules: boolean[][] = []; + + // Indicates function modules that are not subjected to masking. Discarded when constructor finishes. + private readonly isFunction: boolean[][] = []; + + /* -- Constructor (low level) and fields --*/ + // The version number of this QR Code, which is between 1 and 40 (inclusive). + // This determines the size of this barcode. + public version: number; + + // The error correction level used in this QR Code. + public errorCorrectionLevel: Ecc; + + // Creates a new QR Code with the given version number, + // error correction level, data codeword bytes, and mask number. + // This is a low-level API that most users should not use directly. + // A mid-level API is the encodeSegments() function. + public constructor( + // The version number of this QR Code, which is between 1 and 40 (inclusive). + // This determines the size of this barcode. + version: number, + + // The error correction level used in this QR Code. + errorCorrectionLevel: Ecc, + + dataCodewords: Readonly, + + oriMsk: number, + ) { + let msk = oriMsk; + this.version = version; + this.errorCorrectionLevel = errorCorrectionLevel; + // Check scalar arguments + if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION) { + throw new RangeError('Version value out of range'); + } + if (msk < -1 || msk > 7) { + throw new RangeError('Mask value out of range'); + } + this.size = version * 4 + 17; + + // Initialize both grids to be size*size arrays of Boolean false + const row: boolean[] = []; + for (let i = 0; i < this.size; i++) { + row.push(false); + } + for (let i = 0; i < this.size; i++) { + this.modules.push(row.slice()); // Initially all light + this.isFunction.push(row.slice()); + } + + // Compute ECC, draw modules + this.drawFunctionPatterns(); + const allCodewords: number[] = this.addEccAndInterleave(dataCodewords); + this.drawCodewords(allCodewords); + + // Do masking + if (msk === -1) { + // Automatically choose best mask + let minPenalty: number = 1000000000; + for (let i = 0; i < 8; i++) { + this.applyMask(i); + this.drawFormatBits(i); + const penalty: number = this.getPenaltyScore(); + if (penalty < minPenalty) { + msk = i; + minPenalty = penalty; + } + this.applyMask(i); // Undoes the mask due to XOR + } + } + assert(msk >= 0 && msk <= 7); + this.mask = msk; + this.applyMask(msk); // Apply the final choice of mask + this.drawFormatBits(msk); // Overwrite old format bits + + this.isFunction = []; + } + + /* -- Accessor methods --*/ + + // Returns the color of the module (pixel) at the given coordinates, which is false + // for light or true for dark. The top left corner has the coordinates (x=0, y=0). + // If the given coordinates are out of bounds, then false (light) is returned. + public getModule(x: number, y: number): boolean { + return x >= 0 && x < this.size && y >= 0 && y < this.size && this.modules[y][x]; + } + + // Modified to expose modules for easy access + public getModules() { + return this.modules; + } + + /* -- Private helper methods for constructor: Drawing function modules --*/ + + // Reads this object's version field, and draws and marks all function modules. + private drawFunctionPatterns(): void { + // Draw horizontal and vertical timing patterns + for (let i = 0; i < this.size; i++) { + this.setFunctionModule(6, i, i % 2 === 0); + this.setFunctionModule(i, 6, i % 2 === 0); + } + + // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules) + this.drawFinderPattern(3, 3); + this.drawFinderPattern(this.size - 4, 3); + this.drawFinderPattern(3, this.size - 4); + + // Draw numerous alignment patterns + const alignPatPos: number[] = this.getAlignmentPatternPositions(); + const numAlign: number = alignPatPos.length; + for (let i = 0; i < numAlign; i++) { + for (let j = 0; j < numAlign; j++) { + // Don't draw on the three finder corners + if (!((i === 0 && j === 0) || (i === 0 && j === numAlign - 1) || (i === numAlign - 1 && j === 0))) { + this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]); + } + } + } + + // Draw configuration data + this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor + this.drawVersion(); + } + + // Draws two copies of the format bits (with its own error correction code) + // based on the given mask and this object's error correction level field. + private drawFormatBits(mask: number): void { + // Calculate error correction code and pack bits + const data: number = (this.errorCorrectionLevel.formatBits << 3) | mask; // errCorrLvl is unumber2, mask is unumber3 + let rem: number = data; + for (let i = 0; i < 10; i++) { + rem = (rem << 1) ^ ((rem >>> 9) * 0x537); + } + const bits = ((data << 10) | rem) ^ 0x5412; // unumber15 + assert(bits >>> 15 === 0); + + // Draw first copy + for (let i = 0; i <= 5; i++) { + this.setFunctionModule(8, i, getBit(bits, i)); + } + this.setFunctionModule(8, 7, getBit(bits, 6)); + this.setFunctionModule(8, 8, getBit(bits, 7)); + this.setFunctionModule(7, 8, getBit(bits, 8)); + for (let i = 9; i < 15; i++) { + this.setFunctionModule(14 - i, 8, getBit(bits, i)); + } + // Draw second copy + for (let i = 0; i < 8; i++) { + this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i)); + } + for (let i = 8; i < 15; i++) { + this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i)); + } + this.setFunctionModule(8, this.size - 8, true); // Always dark + } + + // Draws two copies of the version bits (with its own error correction code), + // based on this object's version field, iff 7 <= version <= 40. + private drawVersion(): void { + if (this.version < 7) { + return; + } + + // Calculate error correction code and pack bits + let rem: number = this.version; // version is unumber6, in the range [7, 40] + for (let i = 0; i < 12; i++) { + rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); + } + const bits: number = (this.version << 12) | rem; // unumber18 + assert(bits >>> 18 === 0); + + // Draw two copies + for (let i = 0; i < 18; i++) { + const color: boolean = getBit(bits, i); + const a: number = this.size - 11 + (i % 3); + const b: number = Math.floor(i / 3); + this.setFunctionModule(a, b, color); + this.setFunctionModule(b, a, color); + } + } + + // Draws a 9*9 finder pattern including the border separator, + // with the center module at (x, y). Modules can be out of bounds. + private drawFinderPattern(x: number, y: number): void { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const dist: number = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm + const xx: number = x + dx; + const yy: number = y + dy; + if (xx >= 0 && xx < this.size && yy >= 0 && yy < this.size) { + this.setFunctionModule(xx, yy, dist !== 2 && dist !== 4); + } + } + } + } + + // Draws a 5*5 alignment pattern, with the center module + // at (x, y). All modules must be in bounds. + private drawAlignmentPattern(x: number, y: number): void { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1); + } + } + } + + // Sets the color of a module and marks it as a function module. + // Only used by the constructor. Coordinates must be in bounds. + private setFunctionModule(x: number, y: number, isDark: boolean): void { + this.modules[y][x] = isDark; + this.isFunction[y][x] = true; + } + + /* -- Private helper methods for constructor: Codewords and masking --*/ + + // Returns a new byte string representing the given data with the appropriate error correction + // codewords appended to it, based on this object's version and error correction level. + private addEccAndInterleave(data: Readonly): number[] { + const ver: number = this.version; + const ecl: Ecc = this.errorCorrectionLevel; + if (data.length !== QrCode.getNumDataCodewords(ver, ecl)) { + throw new RangeError('Invalid argument'); + } + // Calculate parameter numbers + const numBlocks = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; + const blockEccLen = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver]; + const rawCodewords = Math.floor(QrCode.getNumRawDataModules(ver) / 8); + const numShortBlocks = numBlocks - (rawCodewords % numBlocks); + const shortBlockLen = Math.floor(rawCodewords / numBlocks); + + // Split data numbero blocks and append ECC to each block + const blocks: number[][] = []; + const rsDiv = QrCode.reedSolomonComputeDivisor(blockEccLen); + for (let i = 0, k = 0; i < numBlocks; i++) { + const dat = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1)); + k += dat.length; + const ecc: number[] = QrCode.reedSolomonComputeRemainder(dat, rsDiv); + if (i < numShortBlocks) { + dat.push(0); + } + blocks.push(dat.concat(ecc)); + } + + // Interleave (not concatenate) the bytes from every block numbero a single sequence + const result: number[] = []; + for (let i = 0; i < blocks[0].length; i++) { + blocks.forEach((block, j) => { + // Skip the padding byte in short blocks + if (i !== shortBlockLen - blockEccLen || j >= numShortBlocks) { + result.push(block[i]); + } + }); + } + assert(result.length === rawCodewords); + return result; + } + + // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire + // data area of this QR Code. Function modules need to be marked off before this is called. + private drawCodewords(data: Readonly): void { + if (data.length !== Math.floor(QrCode.getNumRawDataModules(this.version) / 8)) { + throw new RangeError('Invalid argument'); + } + let i: number = 0; // Bit index numbero the data + // Do the funny zigzag scan + for (let right = this.size - 1; right >= 1; right -= 2) { + // Index of right column in each column pair + if (right === 6) { + right = 5; + } + for (let vert = 0; vert < this.size; vert++) { + // Vertical counter + for (let j = 0; j < 2; j++) { + const x: number = right - j; // Actual x coordinate + const upward: boolean = ((right + 1) & 2) === 0; + const y: number = upward ? this.size - 1 - vert : vert; // Actual y coordinate + if (!this.isFunction[y][x] && i < data.length * 8) { + this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7)); + i++; + } + // If this QR Code has any remainder bits (0 to 7), they were assigned as + // 0/false/light by the constructor and are left unchanged by this method + } + } + } + assert(i === data.length * 8); + } + + // XORs the codeword modules in this QR Code with the given mask pattern. + // The function modules must be marked and the codeword bits must be drawn + // before masking. Due to the arithmetic of XOR, calling applyMask() with + // the same mask value a second time will undo the mask. A final well-formed + // QR Code needs exactly one (not zero, two, etc.) mask applied. + private applyMask(mask: number): void { + if (mask < 0 || mask > 7) { + throw new RangeError('Mask value out of range'); + } + for (let y = 0; y < this.size; y++) { + for (let x = 0; x < this.size; x++) { + let invert: boolean; + switch (mask) { + case 0: + invert = (x + y) % 2 === 0; + break; + case 1: + invert = y % 2 === 0; + break; + case 2: + invert = x % 3 === 0; + break; + case 3: + invert = (x + y) % 3 === 0; + break; + case 4: + invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0; + break; + case 5: + invert = ((x * y) % 2) + ((x * y) % 3) === 0; + break; + case 6: + invert = (((x * y) % 2) + ((x * y) % 3)) % 2 === 0; + break; + case 7: + invert = (((x + y) % 2) + ((x * y) % 3)) % 2 === 0; + break; + default: + throw new Error('Unreachable'); + } + if (!this.isFunction[y][x] && invert) { + this.modules[y][x] = !this.modules[y][x]; + } + } + } + } + + // Calculates and returns the penalty score based on state of this QR Code's current modules. + // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. + private getPenaltyScore(): number { + let result: number = 0; + + // Adjacent modules in row having same color, and finder-like patterns + for (let y = 0; y < this.size; y++) { + let runColor = false; + let runX = 0; + const runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let x = 0; x < this.size; x++) { + if (this.modules[y][x] === runColor) { + runX++; + if (runX === 5) { + result += QrCode.PENALTY_N1; + } else if (runX > 5) { + result++; + } + } else { + this.finderPenaltyAddHistory(runX, runHistory); + if (!runColor) { + result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + } + runColor = this.modules[y][x]; + runX = 1; + } + } + result += this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3; + } + // Adjacent modules in column having same color, and finder-like patterns + for (let x = 0; x < this.size; x++) { + let runColor = false; + let runY = 0; + const runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let y = 0; y < this.size; y++) { + if (this.modules[y][x] === runColor) { + runY++; + if (runY === 5) { + result += QrCode.PENALTY_N1; + } else if (runY > 5) { + result++; + } + } else { + this.finderPenaltyAddHistory(runY, runHistory); + if (!runColor) { + result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + } + runColor = this.modules[y][x]; + runY = 1; + } + } + result += this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3; + } + + // 2*2 blocks of modules having same color + for (let y = 0; y < this.size - 1; y++) { + for (let x = 0; x < this.size - 1; x++) { + const color: boolean = this.modules[y][x]; + if ( + color === this.modules[y][x + 1] && + color === this.modules[y + 1][x] && + color === this.modules[y + 1][x + 1] + ) { + result += QrCode.PENALTY_N2; + } + } + } + + // Balance of dark and light modules + let dark: number = 0; + for (const row of this.modules) { + dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark); + } + const total: number = this.size * this.size; // Note that size is odd, so dark/total !== 1/2 + // Compute the smallest numbereger k >= 0 such that (45-5k)% <= dark/total <= (55+5k)% + const k: number = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; + assert(k >= 0 && k <= 9); + result += k * QrCode.PENALTY_N4; + assert(result >= 0 && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4 + return result; + } + + /* -- Private helper functions --*/ + + // Returns an ascending list of positions of alignment patterns for this version number. + // Each position is in the range [0,177), and are used on both the x and y axes. + // This could be implemented as lookup table of 40 variable-length lists of numberegers. + private getAlignmentPatternPositions(): number[] { + if (this.version === 1) { + return []; + } + const numAlign = Math.floor(this.version / 7) + 2; + const step = this.version === 32 ? 26 : Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2; + const result: number[] = [6]; + for (let pos = this.size - 7; result.length < numAlign; pos -= step) { + result.splice(1, 0, pos); + } + return result; + } + + // Returns the number of data bits that can be stored in a QR Code of the given version number, after + // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. + // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. + private static getNumRawDataModules(ver: number): number { + if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION) { + throw new RangeError('Version number out of range'); + } + let result: number = (16 * ver + 128) * ver + 64; + if (ver >= 2) { + const numAlign: number = Math.floor(ver / 7) + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if (ver >= 7) { + result -= 36; + } + } + assert(result >= 208 && result <= 29648); + return result; + } + + // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any + // QR Code of the given version number and error correction level, with remainder bits discarded. + // This stateless pure function could be implemented as a (40*4)-cell lookup table. + private static getNumDataCodewords(ver: number, ecl: Ecc): number { + return ( + Math.floor(QrCode.getNumRawDataModules(ver) / 8) - + QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] * QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver] + ); + } + + // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be + // implemented as a lookup table over all possible parameter values, instead of as an algorithm. + private static reedSolomonComputeDivisor(degree: number): number[] { + if (degree < 1 || degree > 255) { + throw new RangeError('Degree out of range'); + } + // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1. + // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the unumber8 array [255, 8, 93]. + const result: number[] = []; + for (let i = 0; i < degree - 1; i++) { + result.push(0); + } + result.push(1); // Start off with the monomial x^0 + + // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}), + // and drop the highest monomial term which is always 1x^degree. + // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D). + let root = 1; + for (let i = 0; i < degree; i++) { + // Multiply the current product by (x - r^i) + for (let j = 0; j < result.length; j++) { + result[j] = QrCode.reedSolomonMultiply(result[j], root); + if (j + 1 < result.length) { + result[j] ^= result[j + 1]; + } + } + root = QrCode.reedSolomonMultiply(root, 0x02); + } + return result; + } + + // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. + private static reedSolomonComputeRemainder(data: Readonly, divisor: Readonly) { + const result = divisor.map(() => 0); + for (const b of data) { + // Polynomial division + const factor = b ^ result.shift(); + result.push(0); + divisor.forEach((coef, i) => { + result[i] ^= QrCode.reedSolomonMultiply(coef, factor); + }); + } + return result; + } + + // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result + // are unsigned 8-bit numberegers. This could be implemented as a lookup table of 256*256 entries of unumber8. + private static reedSolomonMultiply(x: number, y: number): number { + if (x >>> 8 !== 0 || y >>> 8 !== 0) { + throw new RangeError('Byte out of range'); + } + // Russian peasant multiplication + let z: number = 0; + for (let i = 7; i >= 0; i--) { + z = (z << 1) ^ ((z >>> 7) * 0x11d); + z ^= ((y >>> i) & 1) * x; + } + assert(z >>> 8 === 0); + return z as number; + } + + // Can only be called immediately after a light run is added, and + // returns either 0, 1, or 2. A helper function for getPenaltyScore(). + private finderPenaltyCountPatterns(runHistory: Readonly): number { + const n: number = runHistory[1]; + assert(n <= this.size * 3); + const core: boolean = + n > 0 && runHistory[2] === n && runHistory[3] === n * 3 && runHistory[4] === n && runHistory[5] === n; + return ( + (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) + + (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0) + ); + } + + // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). + private finderPenaltyTerminateAndCount( + currentRunColor: boolean, + oriCurrentRunLength: number, + runHistory: number[], + ): number { + let currentRunLength = oriCurrentRunLength; + if (currentRunColor) { + // Terminate dark run + this.finderPenaltyAddHistory(currentRunLength, runHistory); + currentRunLength = 0; + } + currentRunLength += this.size; // Add light border to final run + this.finderPenaltyAddHistory(currentRunLength, runHistory); + return this.finderPenaltyCountPatterns(runHistory); + } + + // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). + private finderPenaltyAddHistory(oriCurrentRunLength: number, runHistory: number[]) { + let currentRunLength = oriCurrentRunLength; + if (runHistory[0] === 0) { + currentRunLength += this.size; // Add light border to initial run + } + runHistory.pop(); + runHistory.unshift(currentRunLength); + } + + /* -- Constants and tables --*/ + + // The minimum version number supported in the QR Code Model 2 standard. + public static readonly MIN_VERSION: number = 1; + + // The maximum version number supported in the QR Code Model 2 standard. + public static readonly MAX_VERSION: number = 40; + + // For use in getPenaltyScore(), when evaluating which mask is best. + private static readonly PENALTY_N1: number = 3; + + private static readonly PENALTY_N2: number = 3; + + private static readonly PENALTY_N3: number = 40; + + private static readonly PENALTY_N4: number = 10; + + private static readonly ECC_CODEWORDS_PER_BLOCK: number[][] = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [ + -1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, + 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + ], // Low + [ + -1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, + 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, + ], // Medium + [ + -1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, + 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + ], // Quartile + [ + -1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, + 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + ], // High + ]; + + private static readonly NUM_ERROR_CORRECTION_BLOCKS: number[][] = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [ + -1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, + 19, 19, 20, 21, 22, 24, 25, + ], // Low + [ + -1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, + 33, 35, 37, 38, 40, 43, 45, 47, 49, + ], // Medium + [ + -1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, + 43, 45, 48, 51, 53, 56, 59, 62, 65, 68, + ], // Quartile + [ + -1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, + 51, 54, 57, 60, 63, 66, 70, 74, 77, 81, + ], // High + ]; +} diff --git a/src/common/shared/qrcode/types.ts b/src/common/shared/qrcode/types.ts new file mode 100644 index 000000000..0607a98f5 --- /dev/null +++ b/src/common/shared/qrcode/types.ts @@ -0,0 +1,55 @@ +import type { Ecc, QrCode } from './qrcodegen'; + +export type Modules = ReturnType; +export type Excavation = { x: number; y: number; w: number; h: number }; +export type ErrorCorrectionLevel = 'L' | 'M' | 'Q' | 'H'; +export type CrossOrigin = 'anonymous' | 'use-credentials' | '' | undefined; + +export type ERROR_LEVEL_MAPPED_TYPE = { + [index in ErrorCorrectionLevel]: Ecc; +}; + +export type ImageSettings = { + /** + * The URI of the embedded image. + */ + src: string; + /** + * The height, in pixels, of the image. + */ + height: number; + /** + * The width, in pixels, of the image. + */ + width: number; + /** + * Whether or not to "excavate" the modules around the embedded image. This + * means that any modules the embedded image overlaps will use the background + * color. + */ + excavate: boolean; + /** + * The horizontal offset of the embedded image, starting from the top left corner. + * Will center if not specified. + */ + x?: number; + /** + * The vertical offset of the embedded image, starting from the top left corner. + * Will center if not specified. + */ + y?: number; + /** + * The opacity of the embedded image in the range of 0-1. + * @defaultValue 1 + */ + opacity?: number; + /** + * The cross-origin value to use when loading the image. This is used to + * ensure compatibility with CORS, particularly when extracting image data + * from QRCodeCanvas. + * Note: `undefined` is treated differently than the seemingly equivalent + * empty string. This is intended to align with HTML behavior where omitting + * the attribute behaves differently than the empty string. + */ + crossOrigin?: CrossOrigin; +}; diff --git a/src/common/shared/qrcode/utils.ts b/src/common/shared/qrcode/utils.ts new file mode 100644 index 000000000..49db1dcc2 --- /dev/null +++ b/src/common/shared/qrcode/utils.ts @@ -0,0 +1,158 @@ +import type { + CrossOrigin, + ERROR_LEVEL_MAPPED_TYPE, + ErrorCorrectionLevel, + Excavation, + ImageSettings, + Modules, +} from './types'; +import { Ecc } from './qrcodegen'; + +// =================== ERROR_LEVEL ========================== +export const ERROR_LEVEL_MAP: ERROR_LEVEL_MAPPED_TYPE = { + L: Ecc.LOW, + M: Ecc.MEDIUM, + Q: Ecc.QUARTILE, + H: Ecc.HIGH, +} as const; + +// =================== DEFAULT_VALUE ========================== +export const DEFAULT_SIZE = 160; +export const DEFAULT_LEVEL: ErrorCorrectionLevel = 'M'; +export const DEFAULT_BACKGROUND_COLOR = '#FFFFFF'; +export const DEFAULT_FRONT_COLOR = '#000000'; +export const DEFAULT_NEED_MARGIN = false; +export const DEFAULT_MINVERSION = 1; +export const SPEC_MARGIN_SIZE = 4; +export const DEFAULT_MARGIN_SIZE = 0; +export const DEFAULT_IMG_SCALE = 0.1; + +// =================== UTILS ========================== +/** + * Generate a path string from modules + * @param modules + * @param margin + * @returns + */ +export const generatePath = (modules: Modules, margin: number = 0) => { + const ops: string[] = []; + modules.forEach((row, y) => { + let start: number | null = null; + row.forEach((cell, x) => { + if (!cell && start !== null) { + ops.push(`M${start + margin} ${y + margin}h${x - start}v1H${start + margin}z`); + start = null; + return; + } + + if (x === row.length - 1) { + if (!cell) { + return; + } + if (start === null) { + ops.push(`M${x + margin},${y + margin} h1v1H${x + margin}z`); + } else { + ops.push(`M${start + margin},${y + margin} h${x + 1 - start}v1H${start + margin}z`); + } + return; + } + + if (cell && start === null) { + start = x; + } + }); + }); + return ops.join(''); +}; + +/** + * Excavate modules + * @param modules + * @param excavation + * @returns + */ +export const excavateModules = (modules: Modules, excavation: Excavation) => + modules.slice().map((row, y) => { + if (y < excavation.y || y >= excavation.y + excavation.h) { + return row; + } + return row.map((cell, x) => { + if (x < excavation.x || x >= excavation.x + excavation.w) { + return cell; + } + return false; + }); + }); + +/** + * Get image settings + * @param cells The modules of the QR code + * @param size The size of the QR code + * @param margin + * @param imageSettings + * @returns + */ +export const getImageSettings = ( + cells: Modules, + size: number, + margin: number, + imageSettings?: ImageSettings, +): null | { + x: number; + y: number; + h: number; + w: number; + excavation: Excavation | null; + opacity: number; + crossOrigin: CrossOrigin; +} => { + if (imageSettings == null) { + return null; + } + const numCells = cells.length + margin * 2; + const defaultSize = Math.floor(size * DEFAULT_IMG_SCALE); + const scale = numCells / size; + const w = (imageSettings.width || defaultSize) * scale; + const h = (imageSettings.height || defaultSize) * scale; + const x = imageSettings.x == null ? cells.length / 2 - w / 2 : imageSettings.x * scale; + const y = imageSettings.y == null ? cells.length / 2 - h / 2 : imageSettings.y * scale; + const opacity = imageSettings.opacity == null ? 1 : imageSettings.opacity; + + let excavation = null; + if (imageSettings.excavate) { + const floorX = Math.floor(x); + const floorY = Math.floor(y); + const ceilW = Math.ceil(w + x - floorX); + const ceilH = Math.ceil(h + y - floorY); + excavation = { x: floorX, y: floorY, w: ceilW, h: ceilH }; + } + + const { crossOrigin } = imageSettings; + + return { x, y, h, w, excavation, opacity, crossOrigin }; +}; + +/** + * Get margin size + * @param needMargin Whether need margin + * @param marginSize Custom margin size + * @returns + */ +export const getMarginSize = (needMargin: boolean, marginSize?: number) => { + if (marginSize != null) { + return Math.max(Math.floor(marginSize), 0); + } + return needMargin ? SPEC_MARGIN_SIZE : DEFAULT_MARGIN_SIZE; +}; + +/** + * Check if Path2D is supported + */ +export const isSupportPath2d = (() => { + try { + new Path2D().addPath(new Path2D()); + } catch { + return false; + } + return true; +})(); diff --git a/src/common/style/_variables.less b/src/common/style/_variables.less index 41310e40a..b39f2d8a0 100644 --- a/src/common/style/_variables.less +++ b/src/common/style/_variables.less @@ -189,3 +189,6 @@ // 定位 @position-fixed-top: var(--td-position-fixed-top, 0); + +// 遮罩 +@mask-bg: var(--td-mask-background); // 二维码遮罩 diff --git a/src/common/style/theme/_dark.less b/src/common/style/theme/_dark.less index 384667b2a..526ada1b8 100644 --- a/src/common/style/theme/_dark.less +++ b/src/common/style/theme/_dark.less @@ -118,6 +118,7 @@ // 遮罩 --td-mask-active: rgba(0, 0, 0, 40%); // 遮罩-弹出 --td-mask-disabled: rgba(0, 0, 0, 60%); // 遮罩-禁用 + --td-mask-background: rgba(36, 36, 36, 96%); // 二维码遮罩 // 背景色 --td-bg-color-page: var(--td-gray-color-14); // 色彩 - page diff --git a/src/common/style/theme/_light.less b/src/common/style/theme/_light.less index 6ff4ab36f..9e3197f66 100644 --- a/src/common/style/theme/_light.less +++ b/src/common/style/theme/_light.less @@ -118,6 +118,7 @@ // 遮罩 --td-mask-active: rgba(0, 0, 0, 60%); // 遮罩-弹出 --td-mask-disabled: rgba(255, 255, 255, 60%); // 遮罩-禁用 + --td-mask-background: rgba(255, 255, 255, 96%); // 二维码遮罩 // 背景色 --td-bg-color-page: var(--td-gray-color-1); diff --git a/src/qrcode/README.en-US.md b/src/qrcode/README.en-US.md new file mode 100644 index 000000000..569da144f --- /dev/null +++ b/src/qrcode/README.en-US.md @@ -0,0 +1,26 @@ +:: BASE_DOC :: + +## API + +### QRCode Props + +name | type | default | description | required +-- | -- | -- | -- | -- +style | Object | - | CSS(Cascading Style Sheets) | N +custom-style | Object | - | CSS(Cascading Style Sheets),used to set style on virtual component | N +bg-color | String | - | QR code background color | N +borderless | Boolean | false | Is there a border | N +color | String | - | QR code color | N +icon | String | - | The address of the picture in the QR code | N +icon-size | Number / Object | 40 | The size of the picture in the QR code。Typescript:`number \| { width: number; height: number }` | N +level | String | M | QR code error correction level。options: L/M/Q/H | N +size | Number | 160 | QR code size | N +status | String | active | QR code status。options: active/expired/loading/scanned。Typescript:`QRStatus` `type QRStatus = "active" \| "expired" \| "loading" \| "scanned"`。[see more ts definition](https://github.com/Tencent/tdesign-miniprogram/tree/develop/src/qrcode/type.ts) | N +status-render | Slot | - | Custom state renderer。[see more ts definition](https://github.com/Tencent/tdesign-miniprogram/blob/develop/src/common/common.ts)。[see more ts definition](https://github.com/Tencent/tdesign-miniprogram/tree/develop/src/qrcode/type.ts) | N +value | String | - | scanned text | N + +### QRCode Events + +name | params | description +-- | -- | -- +refresh | \- | Click the "Click to refresh" callback diff --git a/src/qrcode/README.md b/src/qrcode/README.md new file mode 100644 index 000000000..7a96921eb --- /dev/null +++ b/src/qrcode/README.md @@ -0,0 +1,111 @@ +--- +title: QRCode 二维码 +description: 二维码能够将文本转换生成二维码的组件,支持自定义配色和 Logo 配置。 +spline: message +isComponent: true +--- + +## 引入 + +全局引入,在 miniprogram 根目录下的`app.json`中配置,局部引入,在需要引入的页面或组件的`index.json`中配置。 + +```json +"usingComponents": { + "t-qrcode": "tdesign-miniprogram/qrcode/qrcode" +} +``` + +## 代码演示 + + 在开发者工具中预览效果 + +
+

Tips: 请确保开发者工具为打开状态。导入开发者工具后,依次执行:npm i > 构建npm包 > 勾选 "将JS编译成ES5"

+
+ +### 01 组件类型 + +#### 基本用法 + +{{ base }} + +#### 带 Icon 的二维码 + +{{ icon }} + + + +#### 二维码纠错等级 + +{{ level }} + +### 02 组件状态 + +{{ status }} + +### 03 组件样式 + +#### 二维码颜色 + +{{ color }} + +#### 二维码尺寸 + +{{ size }} + + +### FAQ + +#### 关于二维码纠错等级 +纠错等级也叫纠错率,就是指二维码可以被遮挡后还能正常扫描,而这个能被遮挡的最大面积就是纠错率。 + +通常情况下二维码分为 4 个纠错级别:`L级` 可纠正约 `7%` 错误、`M级` 可纠正约 `15%` 错误、`Q级` 可纠正约 `25%` 错误、`H级` 可纠正约 `30%` 错误。但并不是所有位置都可以缺损,像最明显的三个角上的方框,直接影响初始定位。中间零散的部分是内容编码,可以容忍缺损。当二维码的内容编码携带信息比较少的时候,也就是链接比较短的时候,设置不同的纠错等级,生成的图片不会发生变化。 +有关更多信息,可参阅[官方文档](https://www.qrcode.com/zh/about/error_correction)的相关资料 + +#### 生成的二维码无法扫描? +若二维码无法扫码识别,可能是因为链接地址过长导致像素过于密集,可以通过 `size` 配置二维码更大,或者通过短链接服务等方式将链接变短。 + +## + +## API + +### QRCode Props + +名称 | 类型 | 默认值 | 描述 | 必传 +-- | -- | -- | -- | -- +style | Object | - | 样式 | N +custom-style | Object | - | 样式,一般用于开启虚拟化组件节点场景 | N +bg-color | String | - | 二维码背景颜色 | N +borderless | Boolean | false | 是否有边框 | N +color | String | - | 二维码颜色 | N +icon | String | - | 二维码中图片的地址 | N +icon-size | Number / Object | 40 | 二维码中图片的大小。TS 类型:`number \| { width: number; height: number }` | N +level | String | M | 二维码纠错等级。可选项:L/M/Q/H | N +size | Number | 160 | 二维码大小 | N +status | String | active | 二维码状态。可选项:active/expired/loading/scanned。TS 类型:`QRStatus` `type QRStatus = "active" \| "expired" \| "loading" \| "scanned"`。[详细类型定义](https://github.com/Tencent/tdesign-miniprogram/tree/develop/src/qrcode/type.ts) | N +status-render | Slot | - | 自定义状态渲染器。[通用类型定义](https://github.com/Tencent/tdesign-miniprogram/blob/develop/src/common/common.ts)。[详细类型定义](https://github.com/Tencent/tdesign-miniprogram/tree/develop/src/qrcode/type.ts) | N +value | String | - | 扫描后的文本 | N + +### QRCode Events + +名称 | 参数 | 描述 +-- | -- | -- +refresh | \- | 点击"点击刷新"的回调 + + +### Progress External Classes + +类名 | 描述 +-- | -- +t-class | 根节点样式类 + +### CSS Variables + + +组件提供了下列 CSS 变量,可用于自定义样式。 + +名称 | 默认值 | 描述 +-- | -- | -- +--td-qrcode-border-color | #dcdcdc | - +--td-qrcode-border-width | 1px | - +--td-qrcode-border-radius | 6px | - \ No newline at end of file diff --git a/src/qrcode/__test__/__snapshots__/demo.test.js.snap b/src/qrcode/__test__/__snapshots__/demo.test.js.snap new file mode 100644 index 000000000..5316ae4d2 --- /dev/null +++ b/src/qrcode/__test__/__snapshots__/demo.test.js.snap @@ -0,0 +1,191 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Qrcode Qrcode base demo works fine 1`] = ` + + + 基本用法 + + + +`; + +exports[`Qrcode Qrcode borderless demo works fine 1`] = ` + + + +`; + +exports[`Qrcode Qrcode color demo works fine 1`] = ` + + + 二维码颜色 + + + + 二维码背景颜色 + + + +`; + +exports[`Qrcode Qrcode icon demo works fine 1`] = ` + + + +`; + +exports[`Qrcode Qrcode level demo works fine 1`] = ` + + + + + +`; + +exports[`Qrcode Qrcode size demo works fine 1`] = ` + + + + + - Smaller + + + + + + Larger + + + + + + + +`; + +exports[`Qrcode Qrcode status demo works fine 1`] = ` + + + + active + + + + + + expired + + + + + + loading + + + + + + scanned + + + + +`; diff --git a/src/qrcode/__test__/demo.test.js b/src/qrcode/__test__/demo.test.js new file mode 100644 index 000000000..8be58ec72 --- /dev/null +++ b/src/qrcode/__test__/demo.test.js @@ -0,0 +1,19 @@ +/** + * 该文件为由脚本 `npm run test:demo` 自动生成,如需修改,执行脚本命令即可。请勿手写直接修改,否则会被覆盖 + */ + +import path from 'path'; +import simulate from 'miniprogram-simulate'; + +const mapper = ['base', 'borderless', 'color', 'icon', 'level', 'size', 'status']; + +describe('Qrcode', () => { + mapper.forEach((demoName) => { + it(`Qrcode ${demoName} demo works fine`, () => { + const id = load(path.resolve(__dirname, `../../qrcode/_example/${demoName}/index`), demoName); + const container = simulate.render(id); + container.attach(document.createElement('parent-wrapper')); + expect(container.toJSON()).toMatchSnapshot(); + }); + }); +}); diff --git a/src/qrcode/__test__/index.test.js b/src/qrcode/__test__/index.test.js new file mode 100644 index 000000000..0ca9ddc84 --- /dev/null +++ b/src/qrcode/__test__/index.test.js @@ -0,0 +1,179 @@ +import path from 'path'; +import simulate from 'miniprogram-simulate'; + +describe('qrcode', () => { + const qrcode = load(path.resolve(__dirname, `../qrcode`)); + + it(`: style && customStyle`, async () => { + const id = simulate.load({ + template: ``, + usingComponents: { + 't-qrcode': qrcode, + }, + data: { + style: 'color: red', + customStyle: 'font-size: 9px', + }, + }); + const comp = simulate.render(id); + comp.attach(document.createElement('parent-wrapper')); + const $qrcode = comp.querySelector('.qrcode >>> .t-qrcode'); + + if (VIRTUAL_HOST) { + expect($qrcode.dom.getAttribute('style').includes(`${comp.data.style}; ${comp.data.customStyle}`)).toBeTruthy(); + } else { + expect($qrcode.dom.getAttribute('style').includes(`${comp.data.customStyle}`)).toBeTruthy(); + } + }); + + it(`: value and size `, () => { + const id = simulate.load({ + template: ``, + data: { + value: 'https://tdesign.tencent.com/', + size: 200, + }, + usingComponents: { + 't-qrcode': qrcode, + }, + }); + + const comp = simulate.render(id); + comp.attach(document.createElement('parent-wrapper')); + + const $qrcode = comp.querySelector('.base'); + expect($qrcode.data.value).toBe('https://tdesign.tencent.com/'); + expect($qrcode.data.size).toBe(200); + }); + + it(`: color and bgColor `, () => { + const id = simulate.load({ + template: ``, + data: { + color: '#0052D9', + bgColor: '#ECF2FE', + }, + usingComponents: { + 't-qrcode': qrcode, + }, + }); + + const comp = simulate.render(id); + comp.attach(document.createElement('parent-wrapper')); + }); + + it(`: icon and iconSize `, () => { + const id = simulate.load({ + template: ``, + data: { + icon: 'https://tdesign.gtimg.com/miniprogram/images/icon.png', + iconSize: 30, + }, + usingComponents: { + 't-qrcode': qrcode, + }, + }); + + const comp = simulate.render(id); + comp.attach(document.createElement('parent-wrapper')); + + const $qrcode = comp.querySelector('.base'); + expect($qrcode.data.icon).toBe('https://tdesign.gtimg.com/miniprogram/images/icon.png'); + expect($qrcode.data.iconSize).toBe(30); + }); + + it(`: level `, () => { + const id = simulate.load({ + template: ``, + data: { + level: 'H', + }, + usingComponents: { + 't-qrcode': qrcode, + }, + }); + + const comp = simulate.render(id); + comp.attach(document.createElement('parent-wrapper')); + + comp.setData({ + level: 'Q', + }); + + const $qrcode = comp.querySelector('.base'); + expect($qrcode.data.level).toBe('Q'); + }); + + it(`: status `, async () => { + const id = simulate.load({ + template: ``, + data: { + status: 'active', + }, + usingComponents: { + 't-qrcode': qrcode, + }, + }); + + const comp = simulate.render(id); + comp.attach(document.createElement('parent-wrapper')); + + comp.setData({ + status: 'expired', + }); + + await simulate.sleep(0); + + const $qrcode = comp.querySelector('.base'); + expect($qrcode.data.status).toBe('expired'); + }); + + it(`: borderless `, () => { + const id = simulate.load({ + template: ``, + data: { + borderless: false, + }, + usingComponents: { + 't-qrcode': qrcode, + }, + }); + + const comp = simulate.render(id); + comp.attach(document.createElement('parent-wrapper')); + + comp.setData({ + borderless: true, + }); + + const container = comp.querySelector('.base >>> .t-qrcode'); + expect(container.dom.getAttribute('class').includes('t-borderless')).toBeTruthy(); + }); + + it(`: refresh event `, async () => { + const handleRefresh = jest.fn(); + const id = simulate.load({ + template: ` + `, + data: { + status: 'expired', + }, + methods: { + handleRefresh, + }, + usingComponents: { + 't-qrcode': qrcode, + }, + }); + + const comp = simulate.render(id); + comp.attach(document.createElement('parent-wrapper')); + + const $qrcode = comp.querySelector('.base'); + expect($qrcode.data.status).toBe('expired'); + }); +}); diff --git a/src/qrcode/_example/base/index.js b/src/qrcode/_example/base/index.js new file mode 100644 index 000000000..b79c5124b --- /dev/null +++ b/src/qrcode/_example/base/index.js @@ -0,0 +1 @@ +Component({}); diff --git a/src/qrcode/_example/base/index.json b/src/qrcode/_example/base/index.json new file mode 100644 index 000000000..94273a4eb --- /dev/null +++ b/src/qrcode/_example/base/index.json @@ -0,0 +1,6 @@ +{ + "component": true, + "usingComponents": { + "t-qrcode": "tdesign-miniprogram/qrcode/qrcode" + } +} diff --git a/src/qrcode/_example/base/index.wxml b/src/qrcode/_example/base/index.wxml new file mode 100644 index 000000000..8894c57c2 --- /dev/null +++ b/src/qrcode/_example/base/index.wxml @@ -0,0 +1,2 @@ + 基本用法 + diff --git a/src/qrcode/_example/base/index.wxss b/src/qrcode/_example/base/index.wxss new file mode 100644 index 000000000..c940b39c4 --- /dev/null +++ b/src/qrcode/_example/base/index.wxss @@ -0,0 +1,7 @@ +.intro-text { + font-size: 14px; + line-height: 22px; + font-weight: 400; + color: rgba(0, 0, 0, 0.6); + margin-bottom: 16px; +} diff --git a/src/qrcode/_example/borderless/index.js b/src/qrcode/_example/borderless/index.js new file mode 100644 index 000000000..b79c5124b --- /dev/null +++ b/src/qrcode/_example/borderless/index.js @@ -0,0 +1 @@ +Component({}); diff --git a/src/qrcode/_example/borderless/index.json b/src/qrcode/_example/borderless/index.json new file mode 100644 index 000000000..94273a4eb --- /dev/null +++ b/src/qrcode/_example/borderless/index.json @@ -0,0 +1,6 @@ +{ + "component": true, + "usingComponents": { + "t-qrcode": "tdesign-miniprogram/qrcode/qrcode" + } +} diff --git a/src/qrcode/_example/borderless/index.wxml b/src/qrcode/_example/borderless/index.wxml new file mode 100644 index 000000000..7b8d59568 --- /dev/null +++ b/src/qrcode/_example/borderless/index.wxml @@ -0,0 +1 @@ + diff --git a/src/qrcode/_example/borderless/index.wxss b/src/qrcode/_example/borderless/index.wxss new file mode 100644 index 000000000..e69de29bb diff --git a/src/qrcode/_example/color/index.js b/src/qrcode/_example/color/index.js new file mode 100644 index 000000000..b79c5124b --- /dev/null +++ b/src/qrcode/_example/color/index.js @@ -0,0 +1 @@ +Component({}); diff --git a/src/qrcode/_example/color/index.json b/src/qrcode/_example/color/index.json new file mode 100644 index 000000000..94273a4eb --- /dev/null +++ b/src/qrcode/_example/color/index.json @@ -0,0 +1,6 @@ +{ + "component": true, + "usingComponents": { + "t-qrcode": "tdesign-miniprogram/qrcode/qrcode" + } +} diff --git a/src/qrcode/_example/color/index.wxml b/src/qrcode/_example/color/index.wxml new file mode 100644 index 000000000..967fb8a81 --- /dev/null +++ b/src/qrcode/_example/color/index.wxml @@ -0,0 +1,18 @@ + 二维码颜色 + + + 二维码背景颜色 + + diff --git a/src/qrcode/_example/color/index.wxss b/src/qrcode/_example/color/index.wxss new file mode 100644 index 000000000..5e94a8e27 --- /dev/null +++ b/src/qrcode/_example/color/index.wxss @@ -0,0 +1,6 @@ +.intro-text { + color: rgba(0, 0, 0, 0.6); + font-size: 14px; + font-weight: 400; + line-height: 22px; +} diff --git a/src/qrcode/_example/icon/index.js b/src/qrcode/_example/icon/index.js new file mode 100644 index 000000000..b79c5124b --- /dev/null +++ b/src/qrcode/_example/icon/index.js @@ -0,0 +1 @@ +Component({}); diff --git a/src/qrcode/_example/icon/index.json b/src/qrcode/_example/icon/index.json new file mode 100644 index 000000000..94273a4eb --- /dev/null +++ b/src/qrcode/_example/icon/index.json @@ -0,0 +1,6 @@ +{ + "component": true, + "usingComponents": { + "t-qrcode": "tdesign-miniprogram/qrcode/qrcode" + } +} diff --git a/src/qrcode/_example/icon/index.wxml b/src/qrcode/_example/icon/index.wxml new file mode 100644 index 000000000..e92fc747c --- /dev/null +++ b/src/qrcode/_example/icon/index.wxml @@ -0,0 +1,5 @@ + + diff --git a/src/qrcode/_example/icon/index.wxss b/src/qrcode/_example/icon/index.wxss new file mode 100644 index 000000000..e69de29bb diff --git a/src/qrcode/_example/level/index.js b/src/qrcode/_example/level/index.js new file mode 100644 index 000000000..4d67c4ff9 --- /dev/null +++ b/src/qrcode/_example/level/index.js @@ -0,0 +1,23 @@ +Component({ + data: { + value: 1, + marks: { + 0: '7%', + 1: '15%', + 2: '25%', + 3: '35%', + }, + currentLevel: 'M', + }, + + methods: { + handleSliderChange(e) { + const { value } = e.detail; + const levels = ['L', 'M', 'Q', 'H']; + this.setData({ + value: value, + currentLevel: levels[value], + }); + }, + }, +}); diff --git a/src/qrcode/_example/level/index.json b/src/qrcode/_example/level/index.json new file mode 100644 index 000000000..be45443ed --- /dev/null +++ b/src/qrcode/_example/level/index.json @@ -0,0 +1,7 @@ +{ + "component": true, + "usingComponents": { + "t-qrcode": "tdesign-miniprogram/qrcode/qrcode", + "t-slider": "tdesign-miniprogram/slider/slider" + } +} diff --git a/src/qrcode/_example/level/index.wxml b/src/qrcode/_example/level/index.wxml new file mode 100644 index 000000000..7420861ae --- /dev/null +++ b/src/qrcode/_example/level/index.wxml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/qrcode/_example/level/index.wxss b/src/qrcode/_example/level/index.wxss new file mode 100644 index 000000000..e69de29bb diff --git a/src/qrcode/_example/qrcode.json b/src/qrcode/_example/qrcode.json new file mode 100644 index 000000000..0158ed3a6 --- /dev/null +++ b/src/qrcode/_example/qrcode.json @@ -0,0 +1,13 @@ +{ + "navigationBarTitleText": "QRCode", + "navigationBarBackgroundColor": "#fff", + "usingComponents": { + "base": "./base", + "size": "./size", + "color": "./color", + "icon": "./icon", + "level": "./level", + "status": "./status", + "borderless": "./borderless" + } +} diff --git a/src/qrcode/_example/qrcode.less b/src/qrcode/_example/qrcode.less new file mode 100644 index 000000000..2b200c211 --- /dev/null +++ b/src/qrcode/_example/qrcode.less @@ -0,0 +1,3 @@ +page { + background-color: var(--td-bg-color-container); +} diff --git a/src/qrcode/_example/qrcode.ts b/src/qrcode/_example/qrcode.ts new file mode 100644 index 000000000..560d44d43 --- /dev/null +++ b/src/qrcode/_example/qrcode.ts @@ -0,0 +1 @@ +Page({}); diff --git a/src/qrcode/_example/qrcode.wxml b/src/qrcode/_example/qrcode.wxml new file mode 100644 index 000000000..d619cdc89 --- /dev/null +++ b/src/qrcode/_example/qrcode.wxml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/qrcode/_example/size/index.js b/src/qrcode/_example/size/index.js new file mode 100644 index 000000000..924b6c350 --- /dev/null +++ b/src/qrcode/_example/size/index.js @@ -0,0 +1,33 @@ +Component({ + data: { + size: 160, + minSize: 80, + maxSize: 240, + }, + lifetimes: {}, + + methods: { + increaseSize() { + if (this.data.size < this.data.maxSize) { + this.setData({ + size: this.data.size + 10, + }); + } else { + this.setData({ + size: this.data.maxSize, + }); + } + }, + decreaseSize() { + if (this.data.size > this.data.minSize) { + this.setData({ + size: this.data.size - 10, + }); + } else { + this.setData({ + size: this.data.minSize, + }); + } + }, + }, +}); diff --git a/src/qrcode/_example/size/index.json b/src/qrcode/_example/size/index.json new file mode 100644 index 000000000..94273a4eb --- /dev/null +++ b/src/qrcode/_example/size/index.json @@ -0,0 +1,6 @@ +{ + "component": true, + "usingComponents": { + "t-qrcode": "tdesign-miniprogram/qrcode/qrcode" + } +} diff --git a/src/qrcode/_example/size/index.wxml b/src/qrcode/_example/size/index.wxml new file mode 100644 index 000000000..e0253496a --- /dev/null +++ b/src/qrcode/_example/size/index.wxml @@ -0,0 +1,11 @@ + + + - Smaller + + + + Larger + + + + + diff --git a/src/qrcode/_example/size/index.wxss b/src/qrcode/_example/size/index.wxss new file mode 100644 index 000000000..5ad523763 --- /dev/null +++ b/src/qrcode/_example/size/index.wxss @@ -0,0 +1,51 @@ +.button-group { + display: flex; + margin-bottom: 16px; +} + +.change-button { + display: flex; + padding: 8px 16px; + justify-content: center; + align-items: center; + gap: 4px; + flex: 1 0 0; + border: 1px solid #dcdcdc; + background: #fff; +} + +.button-disable { + background-color: #e7e7e7; +} + +.button-text-disable { + color: rgba(0, 0, 0, 0.26) !important; +} + +.left-change-button { + border-radius: 6px 0 0 6px; +} + +.right-change-button { + border-radius: 0 6px 6px 0; +} + +.button-text { + color: rgba(0, 0, 0, 0.9); + text-align: center; + font-size: 16px; + font-weight: 600; + line-height: 24px; +} + +.show-container { + display: flex; + height: 320px; + padding: 20px 0; + justify-content: center; + align-items: center; + gap: 10px; + align-self: stretch; + background: #f3f3f3; + max-width: 100%; +} diff --git a/src/qrcode/_example/status/index.js b/src/qrcode/_example/status/index.js new file mode 100644 index 000000000..b3813b6e5 --- /dev/null +++ b/src/qrcode/_example/status/index.js @@ -0,0 +1,27 @@ +Component({ + data: { + status: [ + { value: 'active', label: 'active' }, + { value: 'expired', label: 'expired' }, + { value: 'loading', label: 'loading' }, + { value: 'scanned', label: 'scanned' }, + ], + currentStatus: 'expired', + }, + + methods: { + handleStatusChange(e) { + const selectedValue = e.detail.value; + this.setData({ + currentStatus: selectedValue, + levels: this.data.status.map((item) => ({ + ...item, + checked: item.value === selectedValue, + })), + }); + }, + handleRefresh() { + console.log('Click Refresh'); + }, + }, +}); diff --git a/src/qrcode/_example/status/index.json b/src/qrcode/_example/status/index.json new file mode 100644 index 000000000..94273a4eb --- /dev/null +++ b/src/qrcode/_example/status/index.json @@ -0,0 +1,6 @@ +{ + "component": true, + "usingComponents": { + "t-qrcode": "tdesign-miniprogram/qrcode/qrcode" + } +} diff --git a/src/qrcode/_example/status/index.wxml b/src/qrcode/_example/status/index.wxml new file mode 100644 index 000000000..c6fd48ce5 --- /dev/null +++ b/src/qrcode/_example/status/index.wxml @@ -0,0 +1,8 @@ + + {{item.label}} + + diff --git a/src/qrcode/_example/status/index.wxss b/src/qrcode/_example/status/index.wxss new file mode 100644 index 000000000..ed133ecf6 --- /dev/null +++ b/src/qrcode/_example/status/index.wxss @@ -0,0 +1,12 @@ +.qrcode-container { + margin-bottom: 24rpx; +} + +.status-text { + display: block; + margin-bottom: 16rpx; + font-size: 28rpx; + font-weight: 400; + line-height: 22px; + color: rgba(0, 0, 0, 0.6); +} diff --git a/src/qrcode/components/qrcode-canvas/props.ts b/src/qrcode/components/qrcode-canvas/props.ts new file mode 100644 index 000000000..617c5e03d --- /dev/null +++ b/src/qrcode/components/qrcode-canvas/props.ts @@ -0,0 +1,41 @@ +import { TdQRCodeProps } from './type'; +import { DEFAULT_MARGIN_SIZE, DEFAULT_NEED_MARGIN } from '../../../common/shared/qrcode/utils'; + +export default { + value: { + type: String, + value: '' as TdQRCodeProps['value'], + }, + icon: { + type: String, + value: '' as TdQRCodeProps['icon'], + }, + size: { + type: Number, + value: 160 as TdQRCodeProps['size'], + }, + iconSize: { + type: null, + value: 40 as unknown as TdQRCodeProps['iconSize'], + }, + level: { + type: String, + value: 'M' as TdQRCodeProps['level'], + }, + bgColor: { + type: String, + value: '#ffffff' as TdQRCodeProps['bgColor'], + }, + color: { + type: String, + value: '#000000' as TdQRCodeProps['color'], + }, + includeMargin: { + type: Boolean, + value: DEFAULT_NEED_MARGIN as TdQRCodeProps['includeMargin'], + }, + marginSize: { + type: Number, + value: DEFAULT_MARGIN_SIZE as TdQRCodeProps['marginSize'], + }, +}; diff --git a/src/qrcode/components/qrcode-canvas/qrcode-canvas.json b/src/qrcode/components/qrcode-canvas/qrcode-canvas.json new file mode 100644 index 000000000..467ce2945 --- /dev/null +++ b/src/qrcode/components/qrcode-canvas/qrcode-canvas.json @@ -0,0 +1,3 @@ +{ + "component": true +} diff --git a/src/qrcode/components/qrcode-canvas/qrcode-canvas.less b/src/qrcode/components/qrcode-canvas/qrcode-canvas.less new file mode 100644 index 000000000..451c1d4e2 --- /dev/null +++ b/src/qrcode/components/qrcode-canvas/qrcode-canvas.less @@ -0,0 +1,9 @@ +@import '../../../common/style/base.less'; + +canvas { + width: 100%; + height: 100%; // 移除 wx.canvas 的默认宽高,占满剩余空间 + align-self: stretch; + min-width: 0; + flex: auto; +} diff --git a/src/qrcode/components/qrcode-canvas/qrcode-canvas.ts b/src/qrcode/components/qrcode-canvas/qrcode-canvas.ts new file mode 100644 index 000000000..2a11faca5 --- /dev/null +++ b/src/qrcode/components/qrcode-canvas/qrcode-canvas.ts @@ -0,0 +1,167 @@ +import props from './props'; +import useQRCode from '../../hooks/useQRCode'; +import { TdQRCodeProps } from './type'; +import { SuperComponent, wxComponent } from '../../../common/src/index'; +import { DEFAULT_MINVERSION, excavateModules } from '../../../common/shared/qrcode/utils'; + +@wxComponent() +export default class QRCode extends SuperComponent { + properties = props; + + lifeTimes = { + ready() { + this.checkdefaultValue(); + this.initCanvas(); + }, + }; + + observers = { + '**': function () { + this.checkdefaultValue(); + this.initCanvas(); + }, + }; + + methods = { + async initCanvas() { + const query = wx.createSelectorQuery().in(this); + query + .select('#qrcodeCanvas') + .fields({ node: true, size: true }) + .exec(async (res) => { + if (!res[0]?.node) { + return; + } + + const canvas = res[0].node; + const ctx = canvas.getContext('2d'); + + await this.drawQrcode(canvas, ctx); + }); + }, + + async drawQrcode(canvas: WechatMiniprogram.Canvas, ctx: WechatMiniprogram.CanvasContext) { + if (!ctx) return; + const { value, icon, size, iconSize, level, bgColor, color, includeMargin, marginSize } = this + .properties as TdQRCodeProps; + + const sizeProp = this.getSizeProp(iconSize); + try { + const qrData = useQRCode({ + value, + level: level, + minVersion: DEFAULT_MINVERSION, + includeMargin: includeMargin, + marginSize: marginSize, + size: size, + imageSettings: icon + ? { + src: icon, + width: sizeProp.width, + height: sizeProp.height, + excavate: true, + } + : undefined, + }); + + const windowInfo = wx.getWindowInfo(); + const dpr = windowInfo.pixelRatio || 1; + canvas.width = size * dpr; + canvas.height = size * dpr; + const scale = (size * dpr) / qrData.numCells; + ctx.scale(scale, scale); + + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, qrData.numCells, qrData.numCells); + + let cellsToDraw = qrData.cells; + if (icon && qrData.calculatedImageSettings?.excavation) { + cellsToDraw = excavateModules(qrData.cells, qrData.calculatedImageSettings.excavation); + } + + ctx.fillStyle = color; + cellsToDraw.forEach((row, y) => { + row.forEach((cell, x) => { + if (cell) { + ctx.fillRect(x + qrData.margin, y + qrData.margin, 1.05, 1.05); // 略微大于 1 是抗锯齿处理 + } + }); + }); + if (icon) { + await this.drawCenterIcon( + canvas, + ctx, + qrData.calculatedImageSettings?.w || 0, + qrData.calculatedImageSettings?.h || 0, + qrData.numCells, + ); + } + this.triggerEvent('drawCompleted'); + } catch (err) { + this.triggerEvent('drawError', { error: err }); + } + }, + + async drawCenterIcon( + canvas: WechatMiniprogram.Canvas, + ctx: WechatMiniprogram.CanvasContext, + width: number, + height: number, + numCells: number, + ) { + const img = canvas.createImage(); + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + img.src = this.properties.icon; + }); + + const x = Math.floor((numCells - width) / 2); + const y = Math.floor((numCells - height) / 2); + ctx.globalAlpha = 1; + ctx.drawImage(img as any, x, y, width, height); + }, + + getSizeProp(iconSize: number | { width: number; height: number } | null | undefined) { + if (!iconSize) return { width: 0, height: 0 }; + if (typeof iconSize === 'number') { + return { + width: iconSize, + height: iconSize, + }; + } + return { + width: iconSize.width, + height: iconSize.height, + }; + }, + + checkdefaultValue() { + const updates = { bgColor: '', color: '' }; + let changeFlag = false; + const { bgColor, color } = this.properties; + const { bgColor: defBg, color: defCol } = props; + if (bgColor === '' && defBg.value) { + updates.bgColor = defBg.value; + changeFlag = true; + } + if (color === '' && defCol.value) { + updates.color = defCol.value; + changeFlag = true; + } + changeFlag && this.setData(updates); + }, + // 暴露 canvas 节点给父组件 + getCanvasNode() { + return new Promise((resolve) => { + const query = wx.createSelectorQuery().in(this); + query + .select('#qrcodeCanvas') + .fields({ node: true, size: true }) + .exec((res) => { + resolve(res[0]?.node); + }); + }); + }, + }; +} diff --git a/src/qrcode/components/qrcode-canvas/qrcode-canvas.wxml b/src/qrcode/components/qrcode-canvas/qrcode-canvas.wxml new file mode 100644 index 000000000..be86601d9 --- /dev/null +++ b/src/qrcode/components/qrcode-canvas/qrcode-canvas.wxml @@ -0,0 +1 @@ + diff --git a/src/qrcode/components/qrcode-canvas/type.ts b/src/qrcode/components/qrcode-canvas/type.ts new file mode 100644 index 000000000..2e5f92529 --- /dev/null +++ b/src/qrcode/components/qrcode-canvas/type.ts @@ -0,0 +1,22 @@ +import { ErrorCorrectionLevel } from '../../../common/shared/qrcode/types'; + +export interface TdQRCodeProps { + /** 二维码内容 */ + value?: string; + /** 中心图标路径 */ + icon?: string; + /** 二维码大小(单位rpx) */ + size?: number; + /** 中心图标大小(单位px) */ + iconSize?: null; + /** 纠错等级 */ + level?: ErrorCorrectionLevel; + /** 背景色 */ + bgColor?: string; + /** 二维码颜色 */ + color?: string; + /** 是否包含边距 */ + includeMargin?: boolean; + /** 边距大小(单位rpx) */ + marginSize?: number; +} diff --git a/src/qrcode/components/qrcode-status/props.ts b/src/qrcode/components/qrcode-status/props.ts new file mode 100644 index 000000000..038f5ee88 --- /dev/null +++ b/src/qrcode/components/qrcode-status/props.ts @@ -0,0 +1,24 @@ +import { QRCodeStatusProps } from './type'; + +export default { + status: { + type: String, + value: '' as QRCodeStatusProps['status'], + }, + locale: { + type: Object, + value: { + expiredText: '二维码过期', + refreshText: '点击刷新', + scannedText: '已扫描', + } as QRCodeStatusProps['locale'], + }, + statusRender: { + type: Boolean, + value: false as QRCodeStatusProps['statusRender'], + }, + refresh: { + type: null, + value: null, + }, +}; diff --git a/src/qrcode/components/qrcode-status/qrcode-status.json b/src/qrcode/components/qrcode-status/qrcode-status.json new file mode 100644 index 000000000..4626d23b4 --- /dev/null +++ b/src/qrcode/components/qrcode-status/qrcode-status.json @@ -0,0 +1,7 @@ +{ + "component": true, + "usingComponents": { + "t-loading": "../../../loading/loading", + "t-icon": "../../../icon/icon" + } +} diff --git a/src/qrcode/components/qrcode-status/qrcode-status.less b/src/qrcode/components/qrcode-status/qrcode-status.less new file mode 100644 index 000000000..70fd67eab --- /dev/null +++ b/src/qrcode/components/qrcode-status/qrcode-status.less @@ -0,0 +1,41 @@ +@import '../../../common/style/base.less'; + +@qrcode-expired-link-motion-duration-mid: 0.2s; +@qrcode-expired-link-motion-ease-in-out: cubic-bezier(0.215, 0.61, 0.355, 1); +@qrcode-expired-link-color-hover: var(--td-brand-color-hover); +@qrcode-scanned-icon-color: var(--td-success-color); + +@qrcode-mask-inner-gap: 8px; +@qrcode-mask-inner-btn-height: 32px; + +.@{prefix}-expired { + &__text { + color: @text-color-primary; + font-weight: 600; + } + + &__button { + display: flex; + color: @brand-color; + box-shadow: none; + cursor: pointer; + column-gap: @qrcode-mask-inner-gap; + align-items: center; + height: @qrcode-mask-inner-btn-height; + transition: all @qrcode-expired-link-motion-duration-mid @qrcode-expired-link-motion-ease-in-out; + + &:hover { + color: @qrcode-expired-link-color-hover; + } + } +} + +.@{prefix}-scanned { + display: flex; + column-gap: @qrcode-mask-inner-gap; + align-items: center; + + &__icon { + color: @qrcode-scanned-icon-color; + } +} diff --git a/src/qrcode/components/qrcode-status/qrcode-status.ts b/src/qrcode/components/qrcode-status/qrcode-status.ts new file mode 100644 index 000000000..53fc3d5f1 --- /dev/null +++ b/src/qrcode/components/qrcode-status/qrcode-status.ts @@ -0,0 +1,41 @@ +import props from './props'; +import config from '../../../common/config'; +import { SuperComponent, wxComponent } from '../../../common/src/index'; + +const { prefix } = config; +const name = `${prefix}-qrcode`; + +@wxComponent() +export default class QRCode extends SuperComponent { + options = { + multipleSlots: true, + }; + + properties = { + ...props, + statusRender: { + type: Boolean, + value: false, + }, + }; + + data = { + prefix, + classPrefix: name, + isSkyline: false, + }; + + lifetimes = { + attached() { + this.setData({ + isSkyline: this.renderer === 'skyline', + }); + }, + }; + + methods = { + handleRefresh() { + this.triggerEvent('refresh'); + }, + }; +} diff --git a/src/qrcode/components/qrcode-status/qrcode-status.wxml b/src/qrcode/components/qrcode-status/qrcode-status.wxml new file mode 100644 index 000000000..4b1259ac2 --- /dev/null +++ b/src/qrcode/components/qrcode-status/qrcode-status.wxml @@ -0,0 +1,24 @@ + + + + + + + + {{locale.expiredText}} + + + {{locale.refreshText}} + + + + + + + + + + + {{locale.scannedText}} + + diff --git a/src/qrcode/components/qrcode-status/type.ts b/src/qrcode/components/qrcode-status/type.ts new file mode 100644 index 000000000..21dd58dcb --- /dev/null +++ b/src/qrcode/components/qrcode-status/type.ts @@ -0,0 +1,30 @@ +export interface QRCodeStatusProps { + /** + * 二维码状态 + * @default '' + */ + status?: 'expired' | 'scanned' | ''; + + /** + * 本地化文本配置 + */ + locale?: { + /** 过期提示文本 */ + expiredText?: string; + /** 刷新按钮文本 */ + refreshText?: string; + /** 已扫描提示文本 */ + scannedText?: string; + }; + + /** + * 是否启用自定义渲染 + * @default false + */ + statusRender?: boolean; +} + +export interface QRCodeStatusEvents { + /** 点击刷新时触发 */ + refresh: boolean; +} diff --git a/src/qrcode/hooks/useQRCode.ts b/src/qrcode/hooks/useQRCode.ts new file mode 100644 index 000000000..0bd4ba818 --- /dev/null +++ b/src/qrcode/hooks/useQRCode.ts @@ -0,0 +1,51 @@ +import { QrCode, QrSegment } from '../../common/shared/qrcode/qrcodegen'; +import type { ErrorCorrectionLevel, Excavation, ImageSettings } from '../../common/shared/qrcode/types'; +import { ERROR_LEVEL_MAP, getImageSettings, getMarginSize } from '../../common/shared/qrcode/utils'; + +interface Options { + value: string; + level: ErrorCorrectionLevel; + minVersion: number; + includeMargin: boolean; + marginSize?: number; + imageSettings?: ImageSettings; + size: number; +} + +interface QRCodeResult { + cells: boolean[][]; + margin: number; + numCells: number; + calculatedImageSettings: { + x: number; + y: number; + h: number; + w: number; + excavation: Excavation | null; + opacity: number; + } | null; + qrcode: QrCode; +} + +const useQRCode = (opt: Options): QRCodeResult => { + const { value, level, minVersion, includeMargin, marginSize, imageSettings, size } = opt; + + const qrcode = (() => { + const segments = QrSegment.makeSegments(value); + return QrCode.encodeSegments(segments, ERROR_LEVEL_MAP[level], minVersion); + })(); + + const cells = qrcode.getModules(); + const margin = getMarginSize(includeMargin, marginSize); + const calculatedImageSettings = getImageSettings(cells, size, margin, imageSettings); + + return { + cells, + margin, + numCells: cells.length + margin * 2, + calculatedImageSettings, + qrcode, + }; +}; + +export default useQRCode; diff --git a/src/qrcode/props.ts b/src/qrcode/props.ts new file mode 100644 index 000000000..fa2feeec7 --- /dev/null +++ b/src/qrcode/props.ts @@ -0,0 +1,56 @@ +/* eslint-disable */ + +/** + * 该文件为脚本自动生成文件,请勿随意修改。如需修改请联系 PMC + * */ + +import { TdQRCodeProps } from './type'; +const props: TdQRCodeProps = { + /** 二维码背景颜色 */ + bgColor: { + type: String, + value: '', + }, + /** 是否有边框 */ + borderless: { + type: Boolean, + value: false, + }, + /** 二维码颜色 */ + color: { + type: String, + value: '', + }, + /** 二维码中图片的地址 */ + icon: { + type: String, + value: '', + }, + /** 二维码中图片的大小 */ + iconSize: { + type: null, + value: 40, + }, + /** 二维码纠错等级 */ + level: { + type: String, + value: 'M', + }, + /** 二维码大小 */ + size: { + type: Number, + value: 160, + }, + /** 二维码状态 */ + status: { + type: String, + value: 'active', + }, + /** 扫描后的文本 */ + value: { + type: String, + value: '', + }, +}; + +export default props; diff --git a/src/qrcode/qrcode.json b/src/qrcode/qrcode.json new file mode 100644 index 000000000..461b70523 --- /dev/null +++ b/src/qrcode/qrcode.json @@ -0,0 +1,8 @@ +{ + "component": true, + "styleIsolation": "apply-shared", + "usingComponents": { + "qrcode-canvas": "./components/qrcode-canvas/qrcode-canvas", + "qrcode-status": "./components/qrcode-status/qrcode-status" + } +} diff --git a/src/qrcode/qrcode.less b/src/qrcode/qrcode.less new file mode 100644 index 000000000..bcb919025 --- /dev/null +++ b/src/qrcode/qrcode.less @@ -0,0 +1,42 @@ +@import '../common/style/base.less'; + +@qrcode-padding: 12px; +@qrcode-border-radius: 6px; +@qrcode-z-index: 300; +@qrcode-line-height: 22px; +@qrcode-mask-inner-font-size: var(--td-font-size-title-small); + +.@{prefix}-qrcode { + position: relative; + display: flex; + box-sizing: border-box; + background-color: @bg-color-container; + padding: @qrcode-padding; + border-radius: @qrcode-border-radius; + border: 1px solid @component-border; + + &.@{prefix}-borderless { + border-color: transparent; + } + + .@{prefix}-mask { + left: 0; + top: 0; // 解决 skyline 模式下的偏移问题 + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + z-index: @qrcode-z-index; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + color: @text-color-primary; + line-height: @qrcode-line-height; + background-color: @mask-bg; + text-align: center; + border-radius: @qrcode-border-radius; + font-size: @qrcode-mask-inner-font-size; + } +} diff --git a/src/qrcode/qrcode.md b/src/qrcode/qrcode.md new file mode 100644 index 000000000..b01063f20 --- /dev/null +++ b/src/qrcode/qrcode.md @@ -0,0 +1,25 @@ +:: BASE_DOC :: + +## API + +### QRCode Props + +名称 | 类型 | 默认值 | 描述 | 必传 +-- | -- | -- | -- | -- +bgColor | String | - | 二维码背景颜色 | N +borderless | Boolean | false | 是否有边框 | N +color | String | - | 二维码颜色 | N +icon | String | - | 二维码中图片的地址 | N +iconSize | Number / Object | 40 | 二维码中图片的大小。TS 类型:`number \| { width: number; height: number }` | N +level | String | M | 二维码纠错等级。可选项:L/M/Q/H | N +size | Number | 160 | 二维码大小 | N +status | String | active | 二维码状态。可选项:active/expired/loading/scanned。TS 类型:`QRStatus` `type QRStatus = "active" \| "expired" \| "loading" \| "scanned"`。[详细类型定义](https://github.com/Tencent/tdesign-vue-next/blob/develop/packages/components/qrcode/type.ts) | N +statusRender | Slot / Function | - | 自定义状态渲染器。TS 类型:`(info:StatusRenderInfo) => TNode` `type StatusRenderInfo = {status:QRStatus;onRefresh?: () => void;}`。[通用类型定义](https://github.com/Tencent/tdesign-vue-next/blob/develop/packages/components/common.ts)。[详细类型定义](https://github.com/Tencent/tdesign-vue-next/blob/develop/packages/components/qrcode/type.ts) | N +type | String | canvas | 渲染类型。可选项:canvas/svg | N +value | String | - | 扫描后的文本 | N + +### QRCode Events + +名称 | 参数 | 描述 +-- | -- | -- +refresh | \- | 点击"点击刷新"的回调 diff --git a/src/qrcode/qrcode.ts b/src/qrcode/qrcode.ts new file mode 100644 index 000000000..8272ab9c1 --- /dev/null +++ b/src/qrcode/qrcode.ts @@ -0,0 +1,93 @@ +import props from './props'; +import config from '../common/config'; +import { SuperComponent, wxComponent } from '../common/src/index'; + +const { prefix } = config; +const name = `${prefix}-qrcode`; + +@wxComponent() +export default class QRCode extends SuperComponent { + externalClasses = [`${prefix}-class`]; + + options = { + multipleSlots: true, + virtualHost: true, + }; + + properties = { + ...props, + statusRender: { + type: Boolean, + value: false, + }, + style: { + type: String, + value: '', + }, + customStyle: { + type: String, + value: '', + }, + }; + + data = { + prefix, + showMask: false, + classPrefix: name, + canvasReady: false, + }; + + lifetimes = { + async ready() { + const canvasComp = this.selectComponent('#qrcodeCanvas'); // 获取 canvas 示例 + const canvas = await canvasComp.getCanvasNode(); + this.setData({ canvasNode: canvas }); + }, + + attached() { + this.setData({ + showMask: this.properties.status !== 'active', + }); + }, + }; + + observers = { + status: function (newVal: string) { + this.setData({ + showMask: newVal !== 'active', + }); + }, + }; + + methods = { + handleDrawCompleted() { + this.setData({ + canvasReady: true, + }); + }, + handleDrawError() {}, + handleRefresh() { + this.triggerEvent('refresh'); + }, + // 二维码下载方法 + async handleDownload() { + if (!this.data.canvasNode) { + console.error('未找到 canvas 节点'); + return; + } + + wx.canvasToTempFilePath( + { + canvas: this.data.canvasNode, + success: (res) => { + wx.saveImageToPhotosAlbum({ filePath: res.tempFilePath }); + }, + fail: (err) => { + console.error('canvasToTempFilePath failed', err); + }, + }, + this, + ); + }, + }; +} diff --git a/src/qrcode/qrcode.wxml b/src/qrcode/qrcode.wxml new file mode 100644 index 000000000..93fe596f0 --- /dev/null +++ b/src/qrcode/qrcode.wxml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + diff --git a/src/qrcode/type.ts b/src/qrcode/type.ts new file mode 100644 index 000000000..43829ff0b --- /dev/null +++ b/src/qrcode/type.ts @@ -0,0 +1,84 @@ +/* eslint-disable */ + +/** + * 该文件为脚本自动生成文件,请勿随意修改。如需修改请联系 PMC + * */ + +export interface TdQRCodeProps { + /** + * 二维码背景颜色 + * @default '' + */ + bgColor?: { + type: StringConstructor; + value?: string; + }; + /** + * 是否有边框 + * @default false + */ + borderless?: { + type: BooleanConstructor; + value?: boolean; + }; + /** + * 二维码颜色 + * @default '' + */ + color?: { + type: StringConstructor; + value?: string; + }; + /** + * 二维码中图片的地址 + * @default '' + */ + icon?: { + type: StringConstructor; + value?: string; + }; + /** + * 二维码中图片的大小 + * @default 40 + */ + iconSize?: { + type: null; + value?: number | { width: number; height: number }; + }; + /** + * 二维码纠错等级 + * @default M + */ + level?: { + type: StringConstructor; + value?: 'L' | 'M' | 'Q' | 'H'; + }; + /** + * 二维码大小 + * @default 160 + */ + size?: { + type: NumberConstructor; + value?: number; + }; + /** + * 二维码状态 + * @default active + */ + status?: { + type: StringConstructor; + value?: QRStatus; + }; + /** + * 扫描后的文本 + * @default '' + */ + value?: { + type: StringConstructor; + value?: string; + }; +} + +export type QRStatus = 'active' | 'expired' | 'loading' | 'scanned'; + +export type StatusRenderInfo = { status: QRStatus; onRefresh?: () => void };