/** * Copyright Aquera Inc 2023 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import { html, CSSResultArray, TemplateResult, PropertyValues } from 'lit'; import { customElement, property, query } from 'lit/decorators.js'; import { styles } from './nile-qr-code.css.js'; import { generateQR, ErrorCorrectionLevel } from './nile-qr-code-utils.js'; import NileElement from '../internal/nile-element.js'; /** * Nile QR Code component. * * @tag nile-qr-code * * @csspart base - The canvas element used to draw the QR code. * * @fires nile-qr-image-error - Emitted when the center image fails to load. * * @example * */ @customElement('nile-qr-code') export class NileQrCode extends NileElement { /** * The styles for QR Code. * @remarks If you are extending this class you can extend the base styles with super. Eg `return [super(), myCustomStyles]` */ public static get styles(): CSSResultArray { return [styles]; } // ------------------------------------------------------------------------- // Properties // ------------------------------------------------------------------------- /** * The QR code's value — the text or URL to encode. */ @property({ type: String, reflect: true }) value = ''; /** * The size of the QR code, in pixels. */ @property({ type: Number, reflect: true }) size = 128; /** * The fill color. This can be any valid CSS color, but not a CSS custom property. */ @property({ type: String, reflect: true }) fill = 'black'; /** * The background color. This can be any valid CSS color or `'transparent'`. * It cannot be a CSS custom property. */ @property({ type: String, reflect: true }) background = 'white'; /** * The edge radius of each module. Must be between 0 and 0.5. * Use this to create a rounded effect. */ @property({ type: Number, reflect: true }) radius = 0; /** * The level of error correction to use. * - `'L'` — Low (~7%) * - `'M'` — Medium (~15%) * - `'Q'` — Quartile (~25%) * - `'H'` — High (~30%) * * When using an image overlay, `'H'` is recommended so the QR remains scannable. */ @property({ attribute: 'error-correction', reflect: true }) errorCorrection: ErrorCorrectionLevel = 'H'; /** * The label for assistive devices to announce. * If unspecified, the `value` will be used instead. */ @property({ type: String, reflect: true }) label = ''; /** * URL of an image to overlay in the center of the QR code (e.g. a logo). * The image is drawn on top of the QR modules, so use `error-correction="H"` * to ensure the code remains scannable. */ @property({ type: String, reflect: true }) image = ''; /** * Size of the center image as a fraction of the overall QR code size * (0.1 – 0.4). Defaults to 0.25 (25% of the QR code). */ @property({ attribute: 'image-size', type: Number, reflect: true }) imageSize = 0.25; /** * Padding around the center image in pixels. Creates a clear area * between the image and the surrounding QR modules. */ @property({ attribute: 'image-padding', type: Number, reflect: true }) imagePadding = 4; /** * Optional border radius for the center image in pixels. * Set to a high value for a circular mask. */ @property({ attribute: 'image-radius', type: Number, reflect: true }) imageRadius = 4; /** * Apply a linear gradient to the QR modules instead of a flat color. * Format: `"direction, color1, color2[, ...]"` where direction is an * angle in degrees (e.g. `"135, #6366f1, #ec4899"`). * When set, this overrides the `fill` property for module rendering. */ @property({ attribute: 'fill-gradient', type: String, reflect: true }) fillGradient = ''; // ------------------------------------------------------------------------- // Internal state // ------------------------------------------------------------------------- private _loadedImage: HTMLImageElement | null = null; private _imageLoadPromise: Promise | null = null; // ------------------------------------------------------------------------- // Refs // ------------------------------------------------------------------------- @query('canvas') private canvas!: HTMLCanvasElement; // ------------------------------------------------------------------------- // Lifecycle // ------------------------------------------------------------------------- protected override updated(changedProperties: PropertyValues): void { super.updated(changedProperties); if (changedProperties.has('image')) { this._loadedImage = null; this._imageLoadPromise = null; if (this.image) { this.loadImage(this.image); return; } } const relevantProps: (keyof NileQrCode)[] = [ 'value', 'size', 'fill', 'background', 'radius', 'errorCorrection', 'imageSize', 'imagePadding', 'imageRadius', 'fillGradient', ]; const needsRedraw = relevantProps.some(p => changedProperties.has(p)); if (needsRedraw) { this.drawQrCode(); } } // ------------------------------------------------------------------------- // Rendering // ------------------------------------------------------------------------- public render(): TemplateResult { const ariaLabel = this.label || this.value || 'QR Code'; return html` `; } // ------------------------------------------------------------------------- // Public methods // ------------------------------------------------------------------------- /** * Downloads the QR code as a PNG image. * @param filename - The name of the downloaded file (defaults to `'qr-code.png'`). */ public download(filename = 'qr-code.png'): void { if (!this.canvas) return; const link = document.createElement('a'); link.download = filename; link.href = this.canvas.toDataURL('image/png'); link.click(); } /** * Returns the QR code canvas content as a data URL. * @param type - MIME type (defaults to `'image/png'`). */ public toDataURL(type = 'image/png'): string { if (!this.canvas) return ''; return this.canvas.toDataURL(type); } // ------------------------------------------------------------------------- // Private methods // ------------------------------------------------------------------------- private loadImage(src: string): void { const img = new Image(); img.crossOrigin = 'anonymous'; this._imageLoadPromise = new Promise((resolve) => { img.onload = () => { this._loadedImage = img; this._imageLoadPromise = null; this.drawQrCode(); resolve(); }; img.onerror = () => { this._loadedImage = null; this._imageLoadPromise = null; this.emit('nile-qr-image-error', { src }); this.drawQrCode(); resolve(); }; }); img.src = src; } private drawQrCode(): void { if (!this.canvas) return; const ctx = this.canvas.getContext('2d'); if (!ctx) return; const { size, fill, background, radius: rawRadius, errorCorrection, value } = this; const clampedRadius = Math.max(0, Math.min(0.5, rawRadius)); const matrix = generateQR(value, errorCorrection); const moduleCount = matrix.length; const moduleSize = size / moduleCount; ctx.clearRect(0, 0, size, size); if (background && background !== 'transparent') { ctx.fillStyle = background; ctx.fillRect(0, 0, size, size); } const imgFraction = Math.max(0.1, Math.min(0.4, this.imageSize)); const imgPixelSize = this._loadedImage ? size * imgFraction : 0; const padTotal = this._loadedImage ? this.imagePadding * 2 : 0; const exclusionSize = imgPixelSize + padTotal; const exclusionStart = (size - exclusionSize) / 2; const exclusionEnd = exclusionStart + exclusionSize; const useFill = this.resolveModuleFill(ctx, size); ctx.fillStyle = useFill; for (let row = 0; row < moduleCount; row++) { for (let col = 0; col < moduleCount; col++) { if (matrix[row][col] !== 1) continue; const x = col * moduleSize; const y = row * moduleSize; if (this._loadedImage) { const modRight = x + moduleSize; const modBottom = y + moduleSize; if (x >= exclusionStart && modRight <= exclusionEnd && y >= exclusionStart && modBottom <= exclusionEnd) { continue; } } if (clampedRadius > 0) { this.drawRoundedModule(ctx, x, y, moduleSize, moduleSize, clampedRadius * moduleSize); } else { ctx.fillRect(x, y, moduleSize, moduleSize); } } } if (this._loadedImage) { this.drawCenterImage(ctx, size, imgPixelSize); } } /** * Returns the fill style for QR modules — either a gradient or the flat fill color. */ private resolveModuleFill(ctx: CanvasRenderingContext2D, size: number): string | CanvasGradient { if (!this.fillGradient) return this.fill; const parts = this.fillGradient.split(',').map(s => s.trim()); if (parts.length < 3) return this.fill; const angleDeg = parseFloat(parts[0]); if (Number.isNaN(angleDeg)) return this.fill; const angleRad = (angleDeg * Math.PI) / 180; const cx = size / 2; const cy = size / 2; const len = size / 2; const x0 = cx - Math.cos(angleRad) * len; const y0 = cy - Math.sin(angleRad) * len; const x1 = cx + Math.cos(angleRad) * len; const y1 = cy + Math.sin(angleRad) * len; const gradient = ctx.createLinearGradient(x0, y0, x1, y1); const colors = parts.slice(1); for (let i = 0; i < colors.length; i++) { gradient.addColorStop(i / (colors.length - 1), colors[i]); } return gradient; } private drawCenterImage(ctx: CanvasRenderingContext2D, size: number, imgPixelSize: number): void { if (!this._loadedImage) return; const x = (size - imgPixelSize) / 2; const y = (size - imgPixelSize) / 2; const r = Math.min(this.imageRadius, imgPixelSize / 2); const pad = this.imagePadding; if (pad > 0 && this.background && this.background !== 'transparent') { ctx.fillStyle = this.background; const bx = x - pad; const by = y - pad; const bw = imgPixelSize + pad * 2; const bh = imgPixelSize + pad * 2; const br = r + pad * 0.5; this.drawRoundedRect(ctx, bx, by, bw, bh, br); ctx.fill(); } ctx.save(); this.clipRoundedRect(ctx, x, y, imgPixelSize, imgPixelSize, r); ctx.drawImage(this._loadedImage, x, y, imgPixelSize, imgPixelSize); ctx.restore(); } private drawRoundedModule( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number ): void { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r); ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r); ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r); ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r); ctx.closePath(); ctx.fill(); } private drawRoundedRect( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number ): void { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r); ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r); ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r); ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r); ctx.closePath(); } private clipRoundedRect( ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number ): void { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r); ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r); ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r); ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r); ctx.closePath(); ctx.clip(); } } export default NileQrCode; declare global { interface HTMLElementTagNameMap { 'nile-qr-code': NileQrCode; } }