@dotrino/qr 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/package.json +49 -0
- package/src/index.d.ts +14 -0
- package/src/index.js +14 -0
- package/src/scan.js +222 -0
- package/src/show.js +84 -0
- package/src/svg.js +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 seyacat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# @dotrino/qr
|
|
2
|
+
|
|
3
|
+
> **Parte del ecosistema [Dotrino](https://dotrino.com).** Dotrino es un ecosistema de aplicaciones centradas en la privacidad de los datos: tu información es tuya, y las decisiones sobre ella también — qué compartes, con quién, cuándo y por qué. Sin anuncios, sin cookies, sin rastreo de datos, sin vender tu identidad a nadie.
|
|
4
|
+
|
|
5
|
+
El **QR del ecosistema**, en un solo sitio: dos Web Components (Shadow DOM,
|
|
6
|
+
bilingües es/en) que cualquier app puede usar tal cual.
|
|
7
|
+
|
|
8
|
+
- **`<dotrino-qr>`** — muestra un QR.
|
|
9
|
+
- **`<dotrino-qr-scan>`** — lo lee, con la cámara o desde una foto.
|
|
10
|
+
|
|
11
|
+
Todo ocurre **en el dispositivo**: el QR se dibuja en el cliente y los fotogramas
|
|
12
|
+
de la cámara se decodifican en la propia página. Nada sale a ninguna red — lo que
|
|
13
|
+
va en un QR del ecosistema suele ser del usuario (un enlace con `#fragment`, un
|
|
14
|
+
código de emparejamiento), así que mandarlo a una API de imágenes ajena sería
|
|
15
|
+
justo lo contrario de lo que promete el ecosistema.
|
|
16
|
+
|
|
17
|
+
## Instalación
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm i @dotrino/qr
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Uso
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import '@dotrino/qr'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Mostrar
|
|
30
|
+
|
|
31
|
+
```html
|
|
32
|
+
<dotrino-qr
|
|
33
|
+
value="https://messenger.dotrino.com/#add=K7M2Q9"
|
|
34
|
+
size="200"
|
|
35
|
+
caption="K7M2Q9"></dotrino-qr>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
| Atributo / propiedad | Qué hace |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `value` | lo que se codifica. Sin valor no se dibuja nada. |
|
|
41
|
+
| `size` | lado en píxeles (por defecto 200). |
|
|
42
|
+
| `ecc` | corrección de errores `L`\|`M`\|`Q`\|`H` (por defecto `M`). |
|
|
43
|
+
| `caption` | texto monoespaciado bajo el QR (el código legible, normalmente). |
|
|
44
|
+
|
|
45
|
+
Los módulos van **siempre negro sobre blanco** con zona de silencio: un QR
|
|
46
|
+
tematizado con los colores de la app se vuelve ilegible para media cámara. Lo que
|
|
47
|
+
sí se tematiza es la tarjeta (`--dqr-pad`, `--dqr-radius`, `--dqr-border`,
|
|
48
|
+
`--dqr-caption-size`, `--dqr-caption-color`).
|
|
49
|
+
|
|
50
|
+
### Leer
|
|
51
|
+
|
|
52
|
+
```html
|
|
53
|
+
<dotrino-qr-scan lang="es"></dotrino-qr-scan>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
const scan = document.querySelector('dotrino-qr-scan')
|
|
58
|
+
scan.addEventListener('dotrino-qr-scanned', (e) => { console.log(e.detail.text); scan.open = false })
|
|
59
|
+
scan.addEventListener('dotrino-qr-cancelled', () => { scan.open = false })
|
|
60
|
+
scan.open = true // pide la cámara y abre el modal
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
| Evento | Cuándo | `detail` |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `dotrino-qr-scanned` | leyó un QR (cámara o foto) | `{ text }` |
|
|
66
|
+
| `dotrino-qr-cancelled` | el usuario cerró | — |
|
|
67
|
+
| `dotrino-qr-error` | no hubo cámara, o la foto no tenía QR | `{ code: 'camera' \| 'no-qr' }` |
|
|
68
|
+
|
|
69
|
+
Atributos: `open` (booleano) y `lang` (`es`\|`en`). Variables de tema: `--dqr-bg`,
|
|
70
|
+
`--dqr-text`, `--dqr-muted`, `--dqr-border`, `--dqr-accent`, `--dqr-overlay`,
|
|
71
|
+
`--dqr-radius`, `--dqr-shadow`, `--dqr-z`.
|
|
72
|
+
|
|
73
|
+
**La cámara se apaga sola** al acertar, al cerrar y al sacar el elemento del DOM.
|
|
74
|
+
Si el navegador no da cámara (permiso denegado, contexto no seguro, un ordenador
|
|
75
|
+
sin ella), el modal lo dice y deja el camino de la foto abierto en vez de quedarse
|
|
76
|
+
en negro.
|
|
77
|
+
|
|
78
|
+
### Sin componentes
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
import { qrSvg } from '@dotrino/qr/svg' // string SVG, sin tocar el DOM
|
|
82
|
+
import { scanImageFile } from '@dotrino/qr' // File|Blob → texto | null
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Por qué existe
|
|
86
|
+
|
|
87
|
+
Estaba escrito dos veces —la consola de `dotrino-vault` y la app
|
|
88
|
+
`dotrino-qrreader`— y el messenger iba a ser la tercera. Un QR mal generado o una
|
|
89
|
+
cámara que se queda encendida es el mismo bug repetido en tres sitios, así que
|
|
90
|
+
vive aquí (regla del ecosistema: si falta algo, se extiende el paquete
|
|
91
|
+
compartido; no se copia dentro de la app).
|
|
92
|
+
|
|
93
|
+
**Deuda declarada:** `dotrino-vault/web` y `dotrino-qrreader` siguen con su copia
|
|
94
|
+
propia; migran a este paquete.
|
|
95
|
+
|
|
96
|
+
## Dependencias
|
|
97
|
+
|
|
98
|
+
`qrcode-generator` (dibujar) y `jsqr` (leer). Ambas puras, sin red.
|
|
99
|
+
|
|
100
|
+
## Licencia
|
|
101
|
+
|
|
102
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dotrino/qr",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "UI compartida de Dotrino para códigos QR: <dotrino-qr> (mostrar) y <dotrino-qr-scan> (leer con la cámara o desde una foto). Todo en el cliente, sin servicios de terceros: lo que se codifica puede ser del usuario. Shadow DOM, sin cookies, bilingüe es/en.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"module": "src/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"import": "./src/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./svg": {
|
|
14
|
+
"import": "./src/svg.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"sideEffects": [
|
|
23
|
+
"./src/index.js",
|
|
24
|
+
"./src/show.js",
|
|
25
|
+
"./src/scan.js"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"jsqr": "1.4.0",
|
|
29
|
+
"qrcode-generator": "1.4.4"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"dotrino",
|
|
33
|
+
"qr",
|
|
34
|
+
"scanner",
|
|
35
|
+
"web-component",
|
|
36
|
+
"camera"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "https://github.com/imdotrino/dotrino-qr.git"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"typescript": "5.9.3"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"type-check": "tsc --noEmit"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function qrSvg (text: string, opts?: { ecc?: 'L' | 'M' | 'Q' | 'H', margin?: number }): string
|
|
2
|
+
|
|
3
|
+
export declare class DotrinoQr extends HTMLElement {
|
|
4
|
+
value: string
|
|
5
|
+
size: number
|
|
6
|
+
caption: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export declare class DotrinoQrScan extends HTMLElement {
|
|
10
|
+
open: boolean
|
|
11
|
+
lang: 'es' | 'en'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export declare function scanImageFile (file: Blob): Promise<string | null>
|
package/src/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dotrino/qr — el QR del ecosistema Dotrino, en un solo sitio.
|
|
3
|
+
*
|
|
4
|
+
* import '@dotrino/qr'
|
|
5
|
+
* <dotrino-qr value="..."></dotrino-qr>
|
|
6
|
+
* <dotrino-qr-scan open></dotrino-qr-scan>
|
|
7
|
+
*
|
|
8
|
+
* Se genera y se lee EN EL DISPOSITIVO: lo que va en un QR suele ser del usuario
|
|
9
|
+
* (un enlace con #fragment, un código de emparejamiento), así que no toca ninguna
|
|
10
|
+
* API de imágenes ajena ni sube un fotograma a ninguna parte.
|
|
11
|
+
*/
|
|
12
|
+
export { qrSvg } from './svg.js'
|
|
13
|
+
export { DotrinoQr } from './show.js'
|
|
14
|
+
export { DotrinoQrScan, scanImageFile } from './scan.js'
|
package/src/scan.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <dotrino-qr-scan> — lee un QR con la cámara, o desde una foto si no hay cámara
|
|
3
|
+
* (o el navegador no la da). La mitad "leer" del par; la otra es <dotrino-qr>.
|
|
4
|
+
*
|
|
5
|
+
* const el = document.querySelector('dotrino-qr-scan')
|
|
6
|
+
* el.lang = 'es'
|
|
7
|
+
* el.open = true
|
|
8
|
+
* el.addEventListener('dotrino-qr-scanned', (e) => console.log(e.detail.text))
|
|
9
|
+
* el.addEventListener('dotrino-qr-cancelled', () => { el.open = false })
|
|
10
|
+
*
|
|
11
|
+
* Todo pasa en el dispositivo: los fotogramas se decodifican aquí y no sale nada
|
|
12
|
+
* a ninguna red. La cámara se apaga al cerrar, al acertar y al salir del DOM —
|
|
13
|
+
* una cámara encendida que nadie mira es un fallo, no un detalle.
|
|
14
|
+
*
|
|
15
|
+
* Eventos: 'dotrino-qr-scanned' { text }, 'dotrino-qr-cancelled',
|
|
16
|
+
* 'dotrino-qr-error' { code: 'camera' | 'no-qr' }.
|
|
17
|
+
*/
|
|
18
|
+
import jsQR from 'jsqr'
|
|
19
|
+
|
|
20
|
+
const I18N = {
|
|
21
|
+
es: {
|
|
22
|
+
heading: 'Escanear un QR',
|
|
23
|
+
hint: 'Apunta la cámara al código.',
|
|
24
|
+
photo: '🖼 Usar una foto',
|
|
25
|
+
cancel: 'Cancelar',
|
|
26
|
+
camErr: 'No se pudo abrir la cámara. Puedes usar una foto del código.',
|
|
27
|
+
noQr: 'En esa imagen no hay ningún QR.'
|
|
28
|
+
},
|
|
29
|
+
en: {
|
|
30
|
+
heading: 'Scan a QR',
|
|
31
|
+
hint: 'Point the camera at the code.',
|
|
32
|
+
photo: '🖼 Use a photo',
|
|
33
|
+
cancel: 'Cancel',
|
|
34
|
+
camErr: 'The camera could not be opened. You can use a photo of the code instead.',
|
|
35
|
+
noQr: 'There is no QR in that image.'
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const CSS = `
|
|
40
|
+
:host { font-family: var(--dqr-font-ui, system-ui, -apple-system, Segoe UI, Roboto, sans-serif); }
|
|
41
|
+
.overlay {
|
|
42
|
+
position: fixed; inset: 0; z-index: var(--dqr-z, 1000);
|
|
43
|
+
background: var(--dqr-overlay, rgba(0,0,0,.7));
|
|
44
|
+
display: flex; align-items: center; justify-content: center; padding: 1rem;
|
|
45
|
+
}
|
|
46
|
+
.modal {
|
|
47
|
+
background: var(--dqr-bg, #fff); color: var(--dqr-text, #1b1b1b);
|
|
48
|
+
border-radius: var(--dqr-radius, 14px);
|
|
49
|
+
box-shadow: var(--dqr-shadow, 0 18px 48px rgba(0,0,0,.35));
|
|
50
|
+
width: min(420px, 100%); overflow: hidden;
|
|
51
|
+
}
|
|
52
|
+
h2 { margin: 0; padding: 14px 16px; font-size: 16px; border-bottom: 1px solid var(--dqr-border, rgba(0,0,0,.12)); }
|
|
53
|
+
.stage { position: relative; background: #000; aspect-ratio: 1 / 1; }
|
|
54
|
+
video { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
55
|
+
.frame {
|
|
56
|
+
position: absolute; inset: 12%;
|
|
57
|
+
border: 3px solid var(--dqr-accent, #c0392b); border-radius: 12px;
|
|
58
|
+
box-shadow: 0 0 0 100vmax rgba(0,0,0,.25);
|
|
59
|
+
pointer-events: none;
|
|
60
|
+
}
|
|
61
|
+
.body { padding: 12px 16px 16px; }
|
|
62
|
+
.hint { margin: 0 0 10px; font-size: 13px; color: var(--dqr-muted, #6b6b6b); }
|
|
63
|
+
.err { margin: 0 0 10px; font-size: 13px; color: var(--dqr-accent, #c0392b); }
|
|
64
|
+
.row { display: flex; gap: 8px; justify-content: space-between; }
|
|
65
|
+
button, .file {
|
|
66
|
+
font: inherit; font-size: 14px; cursor: pointer;
|
|
67
|
+
padding: 9px 14px; border-radius: 9px;
|
|
68
|
+
border: 1px solid var(--dqr-border, rgba(0,0,0,.12));
|
|
69
|
+
background: var(--dqr-btn-bg, transparent); color: inherit;
|
|
70
|
+
}
|
|
71
|
+
button:hover, .file:hover { border-color: var(--dqr-accent, #c0392b); }
|
|
72
|
+
input[type=file] { display: none; }
|
|
73
|
+
`
|
|
74
|
+
|
|
75
|
+
/** Decodifica un QR de un archivo de imagen. Devuelve el texto o null. */
|
|
76
|
+
export function scanImageFile (file) {
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
const img = new Image()
|
|
79
|
+
const url = URL.createObjectURL(file)
|
|
80
|
+
img.onload = () => {
|
|
81
|
+
try {
|
|
82
|
+
const c = document.createElement('canvas')
|
|
83
|
+
c.width = img.naturalWidth; c.height = img.naturalHeight
|
|
84
|
+
const ctx = c.getContext('2d')
|
|
85
|
+
ctx.drawImage(img, 0, 0)
|
|
86
|
+
const d = ctx.getImageData(0, 0, c.width, c.height)
|
|
87
|
+
resolve(jsQR(d.data, d.width, d.height)?.data || null)
|
|
88
|
+
} catch (_) { resolve(null) }
|
|
89
|
+
URL.revokeObjectURL(url)
|
|
90
|
+
}
|
|
91
|
+
img.onerror = () => { URL.revokeObjectURL(url); resolve(null) }
|
|
92
|
+
img.src = url
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class DotrinoQrScan extends HTMLElement {
|
|
97
|
+
static get observedAttributes () { return ['open', 'lang'] }
|
|
98
|
+
|
|
99
|
+
#stream = null
|
|
100
|
+
#raf = null
|
|
101
|
+
|
|
102
|
+
constructor () {
|
|
103
|
+
super()
|
|
104
|
+
this.attachShadow({ mode: 'open' })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
get open () { return this.hasAttribute('open') }
|
|
108
|
+
set open (v) { if (v) this.setAttribute('open', ''); else this.removeAttribute('open') }
|
|
109
|
+
|
|
110
|
+
get lang () { return this.getAttribute('lang') === 'en' ? 'en' : 'es' }
|
|
111
|
+
set lang (v) { this.setAttribute('lang', v === 'en' ? 'en' : 'es') }
|
|
112
|
+
|
|
113
|
+
attributeChangedCallback (name) {
|
|
114
|
+
this.#render()
|
|
115
|
+
if (name === 'open') { if (this.open) this.#start(); else this.#stop() }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
connectedCallback () { this.#render(); if (this.open) this.#start() }
|
|
119
|
+
disconnectedCallback () { this.#stop() }
|
|
120
|
+
|
|
121
|
+
#render () {
|
|
122
|
+
const t = I18N[this.lang]
|
|
123
|
+
const root = this.shadowRoot
|
|
124
|
+
if (!this.open) { root.innerHTML = ''; return }
|
|
125
|
+
// Se reconstruye solo al abrir: mientras está cerrado no hay <video> vivo.
|
|
126
|
+
if (root.querySelector('.overlay')) {
|
|
127
|
+
root.querySelector('h2').textContent = t.heading
|
|
128
|
+
root.querySelector('.hint').textContent = t.hint
|
|
129
|
+
root.querySelector('.file').textContent = t.photo
|
|
130
|
+
root.querySelector('.cancel').textContent = t.cancel
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
root.innerHTML = `
|
|
134
|
+
<style>${CSS}</style>
|
|
135
|
+
<div class="overlay" part="overlay">
|
|
136
|
+
<div class="modal" part="modal">
|
|
137
|
+
<h2>${t.heading}</h2>
|
|
138
|
+
<div class="stage"><video playsinline muted autoplay></video><div class="frame"></div></div>
|
|
139
|
+
<div class="body">
|
|
140
|
+
<p class="hint">${t.hint}</p>
|
|
141
|
+
<p class="err" hidden></p>
|
|
142
|
+
<div class="row">
|
|
143
|
+
<label class="file">${t.photo}<input type="file" accept="image/*"></label>
|
|
144
|
+
<button class="cancel" type="button">${t.cancel}</button>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>`
|
|
149
|
+
root.querySelector('.cancel').addEventListener('click', () => {
|
|
150
|
+
this.#stop()
|
|
151
|
+
this.dispatchEvent(new CustomEvent('dotrino-qr-cancelled', { bubbles: true, composed: true }))
|
|
152
|
+
})
|
|
153
|
+
root.querySelector('input[type=file]').addEventListener('change', async (ev) => {
|
|
154
|
+
const f = ev.target.files?.[0]
|
|
155
|
+
ev.target.value = ''
|
|
156
|
+
if (!f) return
|
|
157
|
+
const text = await scanImageFile(f)
|
|
158
|
+
if (text) this.#hit(text)
|
|
159
|
+
else this.#fail('no-qr', I18N[this.lang].noQr)
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#error (msg) {
|
|
164
|
+
const el = this.shadowRoot?.querySelector('.err')
|
|
165
|
+
if (!el) return
|
|
166
|
+
el.textContent = msg || ''
|
|
167
|
+
el.hidden = !msg
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#fail (code, msg) {
|
|
171
|
+
this.#error(msg)
|
|
172
|
+
this.dispatchEvent(new CustomEvent('dotrino-qr-error', { detail: { code }, bubbles: true, composed: true }))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#hit (text) {
|
|
176
|
+
this.#stop()
|
|
177
|
+
this.dispatchEvent(new CustomEvent('dotrino-qr-scanned', { detail: { text }, bubbles: true, composed: true }))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async #start () {
|
|
181
|
+
const video = this.shadowRoot?.querySelector('video')
|
|
182
|
+
if (!video || this.#stream) return
|
|
183
|
+
const canvas = document.createElement('canvas')
|
|
184
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
|
185
|
+
const tick = () => {
|
|
186
|
+
if (!this.#stream) return
|
|
187
|
+
if (video.readyState >= 2 && video.videoWidth) {
|
|
188
|
+
canvas.width = video.videoWidth; canvas.height = video.videoHeight
|
|
189
|
+
try {
|
|
190
|
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
|
191
|
+
const d = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
|
192
|
+
const r = jsQR(d.data, d.width, d.height)
|
|
193
|
+
if (r?.data) return this.#hit(r.data)
|
|
194
|
+
} catch (_) {}
|
|
195
|
+
}
|
|
196
|
+
this.#raf = requestAnimationFrame(tick)
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
this.#stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
|
|
200
|
+
// Cerrar mientras se pedía permiso deja un stream huérfano: se apaga aquí.
|
|
201
|
+
if (!this.open) { this.#stop(); return }
|
|
202
|
+
video.srcObject = this.#stream
|
|
203
|
+
await video.play().catch(() => {})
|
|
204
|
+
this.#raf = requestAnimationFrame(tick)
|
|
205
|
+
} catch (e) {
|
|
206
|
+
this.#stream = null
|
|
207
|
+
console.warn('[dotrino-qr-scan] camera unavailable:', e?.name || e)
|
|
208
|
+
this.#fail('camera', I18N[this.lang].camErr)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#stop () {
|
|
213
|
+
if (this.#raf) { cancelAnimationFrame(this.#raf); this.#raf = null }
|
|
214
|
+
if (this.#stream) { this.#stream.getTracks().forEach((tr) => tr.stop()); this.#stream = null }
|
|
215
|
+
const video = this.shadowRoot?.querySelector('video')
|
|
216
|
+
if (video) video.srcObject = null
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (typeof customElements !== 'undefined' && !customElements.get('dotrino-qr-scan')) {
|
|
221
|
+
customElements.define('dotrino-qr-scan', DotrinoQrScan)
|
|
222
|
+
}
|
package/src/show.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <dotrino-qr> — enseña un QR. La mitad "mostrar" del par; la otra es
|
|
3
|
+
* <dotrino-qr-scan>.
|
|
4
|
+
*
|
|
5
|
+
* <dotrino-qr value="https://messenger.dotrino.com/#add=K7M2Q9" size="200"></dotrino-qr>
|
|
6
|
+
*
|
|
7
|
+
* Atributos/propiedades: value, size (px), ecc ('L'|'M'|'Q'|'H'), caption.
|
|
8
|
+
* El QR se pinta SIEMPRE negro sobre blanco (ver svg.js): la tarjeta se tematiza
|
|
9
|
+
* con las variables --dqr-*, los módulos no.
|
|
10
|
+
*/
|
|
11
|
+
import { qrSvg } from './svg.js'
|
|
12
|
+
|
|
13
|
+
const CSS = `
|
|
14
|
+
:host { display: inline-block; }
|
|
15
|
+
.card {
|
|
16
|
+
background: #fff;
|
|
17
|
+
padding: var(--dqr-pad, 10px);
|
|
18
|
+
border-radius: var(--dqr-radius, 10px);
|
|
19
|
+
border: 1px solid var(--dqr-border, rgba(0,0,0,.12));
|
|
20
|
+
line-height: 0;
|
|
21
|
+
}
|
|
22
|
+
.card svg { display: block; width: 100%; height: 100%; }
|
|
23
|
+
.caption {
|
|
24
|
+
margin-top: 8px;
|
|
25
|
+
font-family: var(--dqr-font, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
26
|
+
font-size: var(--dqr-caption-size, 20px);
|
|
27
|
+
letter-spacing: 3px;
|
|
28
|
+
text-align: center;
|
|
29
|
+
color: var(--dqr-caption-color, inherit);
|
|
30
|
+
line-height: 1.2;
|
|
31
|
+
}
|
|
32
|
+
`
|
|
33
|
+
|
|
34
|
+
export class DotrinoQr extends HTMLElement {
|
|
35
|
+
static get observedAttributes () { return ['value', 'size', 'ecc', 'caption'] }
|
|
36
|
+
|
|
37
|
+
constructor () {
|
|
38
|
+
super()
|
|
39
|
+
this.attachShadow({ mode: 'open' })
|
|
40
|
+
this.shadowRoot.innerHTML = `<style>${CSS}</style><div class="card"></div><div class="caption" hidden></div>`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
connectedCallback () { this.#render() }
|
|
44
|
+
attributeChangedCallback () { this.#render() }
|
|
45
|
+
|
|
46
|
+
get value () { return this.getAttribute('value') || '' }
|
|
47
|
+
set value (v) { if (v == null) this.removeAttribute('value'); else this.setAttribute('value', v) }
|
|
48
|
+
|
|
49
|
+
get size () { return Number(this.getAttribute('size')) || 200 }
|
|
50
|
+
set size (v) { this.setAttribute('size', String(v)) }
|
|
51
|
+
|
|
52
|
+
get caption () { return this.getAttribute('caption') || '' }
|
|
53
|
+
set caption (v) { if (v == null) this.removeAttribute('caption'); else this.setAttribute('caption', v) }
|
|
54
|
+
|
|
55
|
+
#render () {
|
|
56
|
+
const root = this.shadowRoot
|
|
57
|
+
if (!root) return
|
|
58
|
+
const card = root.querySelector('.card')
|
|
59
|
+
const cap = root.querySelector('.caption')
|
|
60
|
+
const value = this.value
|
|
61
|
+
// Sin valor no se dibuja un QR vacío: un QR que no lleva a ninguna parte se
|
|
62
|
+
// escanea igual y deja al usuario mirando un error que no es suyo.
|
|
63
|
+
if (!value) { card.innerHTML = ''; card.hidden = true; cap.hidden = true; return }
|
|
64
|
+
card.hidden = false
|
|
65
|
+
const px = this.size
|
|
66
|
+
card.style.width = px + 'px'
|
|
67
|
+
card.style.height = px + 'px'
|
|
68
|
+
const ecc = (this.getAttribute('ecc') || 'M').toUpperCase()
|
|
69
|
+
try {
|
|
70
|
+
card.innerHTML = qrSvg(value, { ecc: ['L', 'M', 'Q', 'H'].includes(ecc) ? ecc : 'M' })
|
|
71
|
+
} catch (e) {
|
|
72
|
+
// Pasa cuando el texto no cabe ni en la versión 40. Es un fallo del llamador
|
|
73
|
+
// y se dice, no se pinta medio QR.
|
|
74
|
+
card.innerHTML = ''
|
|
75
|
+
console.error('[dotrino-qr] no se pudo generar el QR:', e?.message || e)
|
|
76
|
+
}
|
|
77
|
+
cap.textContent = this.caption
|
|
78
|
+
cap.hidden = !this.caption
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (typeof customElements !== 'undefined' && !customElements.get('dotrino-qr')) {
|
|
83
|
+
customElements.define('dotrino-qr', DotrinoQr)
|
|
84
|
+
}
|
package/src/svg.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* qrSvg — dibuja un QR como SVG en el cliente.
|
|
3
|
+
*
|
|
4
|
+
* Sin servicios de terceros a propósito: lo que se codifica suele ser del usuario
|
|
5
|
+
* (un enlace con #fragment, un código de emparejamiento) y mandarlo a una API de
|
|
6
|
+
* imágenes lo pondría en un servidor ajeno, que es justo lo que el ecosistema evita.
|
|
7
|
+
*
|
|
8
|
+
* Negro sobre blanco con zona de silencio, pase lo que pase con el tema de la app:
|
|
9
|
+
* un QR con poco contraste no lo lee ninguna cámara.
|
|
10
|
+
*/
|
|
11
|
+
import qrcode from 'qrcode-generator'
|
|
12
|
+
|
|
13
|
+
// La librería trae dos codificadores de texto; el de UTF-8 es el que respeta los
|
|
14
|
+
// acentos y la eñe. Sin esto, un enlace con tilde se lee como basura.
|
|
15
|
+
try { qrcode.stringToBytes = qrcode.stringToBytesFuncs['UTF-8'] } catch (_) {}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} text lo que se codifica
|
|
19
|
+
* @param {object} [opts]
|
|
20
|
+
* @param {'L'|'M'|'Q'|'H'} [opts.ecc='M'] corrección de errores
|
|
21
|
+
* @param {number} [opts.margin=4] zona de silencio, en módulos
|
|
22
|
+
* @returns {string} SVG listo para inyectar
|
|
23
|
+
*/
|
|
24
|
+
export function qrSvg (text, { ecc = 'M', margin = 4 } = {}) {
|
|
25
|
+
const qr = qrcode(0, ecc)
|
|
26
|
+
qr.addData(String(text ?? ''))
|
|
27
|
+
qr.make()
|
|
28
|
+
const n = qr.getModuleCount()
|
|
29
|
+
const dim = n + margin * 2
|
|
30
|
+
let rects = ''
|
|
31
|
+
for (let r = 0; r < n; r++) {
|
|
32
|
+
for (let c = 0; c < n; c++) {
|
|
33
|
+
if (qr.isDark(r, c)) rects += `<rect x="${c + margin}" y="${r + margin}" width="1" height="1"/>`
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${dim} ${dim}" ` +
|
|
37
|
+
'shape-rendering="crispEdges" role="img" aria-label="QR">' +
|
|
38
|
+
`<rect width="${dim}" height="${dim}" fill="#fff"/><g fill="#000">${rects}</g></svg>`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default qrSvg
|