stash
This commit is contained in:
parent
7d9f67a77a
commit
3299c97392
13 changed files with 1407 additions and 327 deletions
227
frontend/package-lock.json
generated
227
frontend/package-lock.json
generated
|
|
@ -569,11 +569,6 @@
|
||||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"camelcase": {
|
|
||||||
"version": "5.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
|
||||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
|
|
||||||
},
|
|
||||||
"chai": {
|
"chai": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
|
||||||
|
|
@ -614,68 +609,11 @@
|
||||||
"readdirp": "~3.6.0"
|
"readdirp": "~3.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cliui": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
|
||||||
"requires": {
|
|
||||||
"string-width": "^4.2.0",
|
|
||||||
"strip-ansi": "^6.0.0",
|
|
||||||
"wrap-ansi": "^6.2.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-regex": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
|
|
||||||
},
|
|
||||||
"ansi-styles": {
|
|
||||||
"version": "4.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
|
||||||
"requires": {
|
|
||||||
"color-convert": "^2.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"emoji-regex": {
|
|
||||||
"version": "8.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
|
|
||||||
},
|
|
||||||
"string-width": {
|
|
||||||
"version": "4.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
|
||||||
"requires": {
|
|
||||||
"emoji-regex": "^8.0.0",
|
|
||||||
"is-fullwidth-code-point": "^3.0.0",
|
|
||||||
"strip-ansi": "^6.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"strip-ansi": {
|
|
||||||
"version": "6.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
|
||||||
"requires": {
|
|
||||||
"ansi-regex": "^5.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"wrap-ansi": {
|
|
||||||
"version": "6.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
|
||||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
|
||||||
"requires": {
|
|
||||||
"ansi-styles": "^4.0.0",
|
|
||||||
"string-width": "^4.1.0",
|
|
||||||
"strip-ansi": "^6.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"color-convert": {
|
"color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
}
|
}
|
||||||
|
|
@ -683,7 +621,8 @@
|
||||||
"color-name": {
|
"color-name": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"combined-stream": {
|
"combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
|
|
@ -786,11 +725,6 @@
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"decamelize": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="
|
|
||||||
},
|
|
||||||
"decimal.js": {
|
"decimal.js": {
|
||||||
"version": "10.4.3",
|
"version": "10.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
|
||||||
|
|
@ -812,11 +746,6 @@
|
||||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"dijkstrajs": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
|
|
||||||
},
|
|
||||||
"dns-query": {
|
"dns-query": {
|
||||||
"version": "0.11.2",
|
"version": "0.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/dns-query/-/dns-query-0.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/dns-query/-/dns-query-0.11.2.tgz",
|
||||||
|
|
@ -926,15 +855,6 @@
|
||||||
"to-regex-range": "^5.0.1"
|
"to-regex-range": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"find-up": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
|
||||||
"requires": {
|
|
||||||
"locate-path": "^5.0.0",
|
|
||||||
"path-exists": "^4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"foreground-child": {
|
"foreground-child": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
|
||||||
|
|
@ -963,11 +883,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"get-caller-file": {
|
|
||||||
"version": "2.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
|
|
||||||
},
|
|
||||||
"get-func-name": {
|
"get-func-name": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
|
||||||
|
|
@ -1077,7 +992,8 @@
|
||||||
"is-fullwidth-code-point": {
|
"is-fullwidth-code-point": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"is-glob": {
|
"is-glob": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
|
|
@ -1188,14 +1104,6 @@
|
||||||
"integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==",
|
"integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"locate-path": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
|
||||||
"requires": {
|
|
||||||
"p-locate": "^4.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lodash": {
|
"lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
|
@ -1322,29 +1230,6 @@
|
||||||
"yocto-queue": "^1.0.0"
|
"yocto-queue": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"p-locate": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
|
||||||
"requires": {
|
|
||||||
"p-limit": "^2.2.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"p-limit": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
|
||||||
"requires": {
|
|
||||||
"p-try": "^2.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"p-try": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
|
|
||||||
},
|
|
||||||
"package-json-from-dist": {
|
"package-json-from-dist": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz",
|
||||||
|
|
@ -1360,11 +1245,6 @@
|
||||||
"entities": "^4.4.0"
|
"entities": "^4.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"path-exists": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="
|
|
||||||
},
|
|
||||||
"path-key": {
|
"path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
|
@ -1415,11 +1295,6 @@
|
||||||
"pathe": "^1.1.2"
|
"pathe": "^1.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pngjs": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="
|
|
||||||
},
|
|
||||||
"popper.js": {
|
"popper.js": {
|
||||||
"version": "1.16.1",
|
"version": "1.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
|
||||||
|
|
@ -1478,16 +1353,6 @@
|
||||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"qrcode": {
|
|
||||||
"version": "1.5.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
|
||||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
|
||||||
"requires": {
|
|
||||||
"dijkstrajs": "^1.0.1",
|
|
||||||
"pngjs": "^5.0.0",
|
|
||||||
"yargs": "^15.3.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"querystringify": {
|
"querystringify": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||||
|
|
@ -1509,16 +1374,6 @@
|
||||||
"picomatch": "^2.2.1"
|
"picomatch": "^2.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"require-directory": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="
|
|
||||||
},
|
|
||||||
"require-main-filename": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
|
|
||||||
},
|
|
||||||
"requires-port": {
|
"requires-port": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||||
|
|
@ -1572,11 +1427,6 @@
|
||||||
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
|
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"set-blocking": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
|
|
||||||
},
|
|
||||||
"shebang-command": {
|
"shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|
@ -1973,11 +1823,6 @@
|
||||||
"isexe": "^2.0.0"
|
"isexe": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"which-module": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
|
|
||||||
},
|
|
||||||
"why-is-node-running": {
|
"why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
|
@ -2071,68 +1916,6 @@
|
||||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"y18n": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
|
|
||||||
},
|
|
||||||
"yargs": {
|
|
||||||
"version": "15.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
|
||||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
|
||||||
"requires": {
|
|
||||||
"cliui": "^6.0.0",
|
|
||||||
"decamelize": "^1.2.0",
|
|
||||||
"find-up": "^4.1.0",
|
|
||||||
"get-caller-file": "^2.0.1",
|
|
||||||
"require-directory": "^2.1.1",
|
|
||||||
"require-main-filename": "^2.0.0",
|
|
||||||
"set-blocking": "^2.0.0",
|
|
||||||
"string-width": "^4.2.0",
|
|
||||||
"which-module": "^2.0.0",
|
|
||||||
"y18n": "^4.0.0",
|
|
||||||
"yargs-parser": "^18.1.2"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-regex": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
|
|
||||||
},
|
|
||||||
"emoji-regex": {
|
|
||||||
"version": "8.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
|
|
||||||
},
|
|
||||||
"string-width": {
|
|
||||||
"version": "4.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
|
||||||
"requires": {
|
|
||||||
"emoji-regex": "^8.0.0",
|
|
||||||
"is-fullwidth-code-point": "^3.0.0",
|
|
||||||
"strip-ansi": "^6.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"strip-ansi": {
|
|
||||||
"version": "6.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
|
||||||
"requires": {
|
|
||||||
"ansi-regex": "^5.0.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"yargs-parser": {
|
|
||||||
"version": "18.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
|
||||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
|
||||||
"requires": {
|
|
||||||
"camelcase": "^5.0.0",
|
|
||||||
"decamelize": "^1.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"yocto-queue": {
|
"yocto-queue": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@
|
||||||
"dns-query": "^0.11.2",
|
"dns-query": "^0.11.2",
|
||||||
"js-nacl": "^1.4.0",
|
"js-nacl": "^1.4.0",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"qrcode": "^1.5.4",
|
|
||||||
"vue": "^3.2.47",
|
"vue": "^3.2.47",
|
||||||
"vue-multiselect": "^2.1.7",
|
"vue-multiselect": "^2.1.7",
|
||||||
"vue-router": "^4.1.6",
|
"vue-router": "^4.1.6",
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {drawFallbackLabel} from "@/label.js";
|
import {drawFallbackLabel, preloadQrEncoder} from "@/label.js";
|
||||||
import {LABEL_TEMPLATES, templateIsAvailable, templateContent} from "@/label-layouts.js";
|
import {LABEL_TEMPLATES, templateIsAvailable, templateContent} from "@/label-layouts.js";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
@ -69,6 +69,13 @@ export default {
|
||||||
value: {
|
value: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
// Print.vue's global qr/micro-qr/rmqr choice (see label.js's QR_CODE_TYPES) - passed
|
||||||
|
// straight through to drawFallbackLabel so these thumbnails match whatever symbology the
|
||||||
|
// main preview is actually using.
|
||||||
|
codeType: {
|
||||||
|
type: String,
|
||||||
|
default: "qr"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
model: {
|
model: {
|
||||||
|
|
@ -84,6 +91,9 @@ export default {
|
||||||
watch: {
|
watch: {
|
||||||
fields() {
|
fields() {
|
||||||
this.redraw();
|
this.redraw();
|
||||||
|
},
|
||||||
|
codeType() {
|
||||||
|
this.redraw();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -116,7 +126,7 @@ export default {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
drawFallbackLabel(canvas, content);
|
drawFallbackLabel(canvas, content, "along", this.codeType);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// A thumbnail that can't render at this content length just stays blank
|
// A thumbnail that can't render at this content length just stays blank
|
||||||
// rather than surfacing an error for every keystroke.
|
// rather than surfacing an error for every keystroke.
|
||||||
|
|
@ -131,7 +141,10 @@ export default {
|
||||||
// array rather than the canvas itself. A function ref sidesteps that entirely.
|
// array rather than the canvas itself. A function ref sidesteps that entirely.
|
||||||
this.templateCanvases = {};
|
this.templateCanvases = {};
|
||||||
},
|
},
|
||||||
mounted() {
|
async mounted() {
|
||||||
|
// See Print.vue's mounted() - every thumbnail here has a "qrcode" leaf, so there's
|
||||||
|
// nothing worth drawing before this resolves.
|
||||||
|
await preloadQrEncoder();
|
||||||
this.redraw();
|
this.redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,12 @@
|
||||||
<span class="align-middle">Print</span>
|
<span class="align-middle">Print</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="sidebar-item">
|
||||||
|
<router-link to="/scan" class="sidebar-link">
|
||||||
|
<b-icon-qr-code-scan class="bi-valign-middle"></b-icon-qr-code-scan>
|
||||||
|
<span class="align-middle">Scan</span>
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="sidebar-version" :title="versionTitle">{{ frontendCommit }}{{ backendCommit ? ' / ' + backendCommit : '' }}</div>
|
<div class="sidebar-version" :title="versionTitle">{{ frontendCommit }}{{ backendCommit ? ' / ' + backendCommit : '' }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
9
frontend/src/handle-url.js
Normal file
9
frontend/src/handle-url.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
// Embeds/extracts a handle in a URL path segment. See docs/federation.md's "Embedding a
|
||||||
|
// `#`-bearing handle in a URL": `#` starts a URI's fragment component, so a group handle
|
||||||
|
// (`#name@domain`) or classification handle (`origin#type:name`) can't appear unescaped in a
|
||||||
|
// path segment. `+` stands in for `#` there instead of the usual %23 - safe to reverse
|
||||||
|
// unambiguously because every field a handle is built from is already required to exclude `+`
|
||||||
|
// (see federation.md's Reserved characters). The canonical handle itself never changes; this only
|
||||||
|
// affects how one gets embedded in, or read back out of, a URL path segment.
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,5 +1,36 @@
|
||||||
import QRCode from "qrcode";
|
import {loadAnyDCode} from "../vendor/anyd-qr.js";
|
||||||
import {encodeHandleForUrl} from "@/handle-url";
|
import {encodeHandleForUrl} from "@/router"
|
||||||
|
|
||||||
|
// anyd-qr.js's own loadAnyDCode() memoizes the wasm instantiation itself, so calling it more
|
||||||
|
// than once (each of Print.vue and LabelLayoutPreview.vue does, on mount) is free - `anyd` just
|
||||||
|
// mirrors its resolved value so buildRenderTree below can use it synchronously. Until it
|
||||||
|
// resolves, a "qrcode" leaf throws (see encodeQr) the same way an oversized value already does -
|
||||||
|
// callers already have to handle layoutContent throwing, so this reuses that path rather than
|
||||||
|
// adding a second failure mode.
|
||||||
|
let anyd = null;
|
||||||
|
|
||||||
|
export function preloadQrEncoder() {
|
||||||
|
return loadAnyDCode().then(instance => { anyd = instance; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three symbologies anyd-qr.js exposes (see its CodeType) - Print.vue's code-type selector
|
||||||
|
// offers exactly these. rMQR's matrix isn't square (see encodeQr's width/height below), unlike
|
||||||
|
// qr/micro-qr, which always are.
|
||||||
|
export const QR_CODE_TYPES = ["qr", "micro-qr", "rmqr"];
|
||||||
|
|
||||||
|
function encodeQr(text, codeType) {
|
||||||
|
if (!anyd) {
|
||||||
|
throw new Error("The QR encoder is still loading — try again in a moment.");
|
||||||
|
}
|
||||||
|
// BitMatrix-alike view over anyd's row-major Uint8Array, matching the shape drawQrLeaf/
|
||||||
|
// snapQrToCrispSize below expect (they predate this and were written against the "qrcode"
|
||||||
|
// package's own modules.size/get()). anyd's matrix already excludes the quiet zone from
|
||||||
|
// width/height (see its ModuleMatrix type), same as the old library's BitMatrix. width/height
|
||||||
|
// are kept separate rather than a single `size` (the old library's own shape, always square)
|
||||||
|
// since rMQR symbols are rectangular.
|
||||||
|
const {width, height, modules} = anyd.encode(codeType, new TextEncoder().encode(text), {ecc: "M"}).matrix;
|
||||||
|
return {width, height, get: (row, col) => modules[row * width + col] !== 0};
|
||||||
|
}
|
||||||
|
|
||||||
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
|
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
|
||||||
|
|
||||||
|
|
@ -39,27 +70,46 @@ export function tapeFromStatus(status) {
|
||||||
|
|
||||||
See label-layouts.js's LABEL_TEMPLATES for concrete trees.
|
See label-layouts.js's LABEL_TEMPLATES for concrete trees.
|
||||||
*/
|
*/
|
||||||
const QR_ASPECT = 1; /* a QR code is always square */
|
|
||||||
const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural aspect ratio at */
|
const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural aspect ratio at */
|
||||||
|
|
||||||
// Below 10px, a general-purpose sans-serif gets blurry/illegible, so drawTextLeaf switches to
|
// Below 10px, a general-purpose sans-serif gets blurry/illegible, so drawTextLeaf switches to one
|
||||||
// whichever of these bitmap-style fonts (see ../scss/_pixel-fonts.scss) is designed closest to -
|
// of these bitmap-style fonts instead (see ../scss/_pixel-fonts.scss) - Tom Thumb for the smallest
|
||||||
// and no smaller than - the box's actual effective size. Ordered smallest first; fontFamilyFor
|
// sizes, Silkscreen once there's enough room for its more conventional letterforms.
|
||||||
// below picks the first tier whose belowPx clears the requested size, sans-serif once none do.
|
//
|
||||||
|
// Both were chosen only after rendering single letters in a real browser *at raw canvas pixel
|
||||||
|
// sizes* and inspecting the actual pixels - checking that fillText was merely *called* doesn't
|
||||||
|
// confirm anything legible got drawn, and neither does a DPI-adjusted size that was never the
|
||||||
|
// number actually handed to ctx.font. Silkscreen confirmed clean at 8px+. A third candidate,
|
||||||
|
// PICO-8, also rendered cleanly across the whole range, but has no lowercase glyphs at all - it
|
||||||
|
// silently draws lowercase input as uppercase - which rules it out for real label content (item
|
||||||
|
// handles, URLs) that isn't reliably all-caps. Tom Thumb's declared ascent/descent (0 / ~fontPx,
|
||||||
|
// backwards from a normal font) turned out not to be a centering quirk: its actual visible ink is
|
||||||
|
// only ~1/3.2 of its own nominal font-size (confirmed both by measuring actualBoundingBox at
|
||||||
|
// several sizes and by a live-browser check - "16px" reads as roughly 5px of real glyph height),
|
||||||
|
// hence the `scale` below - whatever logical size is requested, the font is actually drawn that
|
||||||
|
// many times larger so its real ink comes out at the intended size. Silkscreen's declared size
|
||||||
|
// already matches its ink, so it has no `scale` (equivalent to 1).
|
||||||
|
//
|
||||||
|
// belowPx and MIN_READABLE_TEXT_PX below are both compared against the *logical* (unscaled)
|
||||||
|
// fontPx, deliberately not adjusted for the tape's dpi: a browser's font rasterizer only ever
|
||||||
|
// sees a raw pixel count, with no notion of "physical size" at all, so that's what determines
|
||||||
|
// whether a glyph's fine detail survives - confirmed by the same real-Chromium testing, where a
|
||||||
|
// raw 4.35px render was a solid blob regardless of what a dpi-scaled version of that number would
|
||||||
|
// have implied.
|
||||||
const PIXEL_FONT_TIERS = [
|
const PIXEL_FONT_TIERS = [
|
||||||
{belowPx: 6, family: "Tom Thumb"}, /* designed for ~5px */
|
{belowPx: 8, family: "Tom Thumb", scale: 3.2},
|
||||||
{belowPx: 7, family: "PICO-8"}, /* designed for ~6px */
|
{belowPx: 10, family: "Silkscreen"},
|
||||||
{belowPx: 10, family: "Silkscreen"}, /* designed for ~7-8px */
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function fontFamilyFor(fontPx) {
|
function fontFamilyFor(fontPx) {
|
||||||
return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx)?.family ?? "sans-serif";
|
return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx) ?? {family: "sans-serif"};
|
||||||
}
|
}
|
||||||
|
|
||||||
// A pixel font (see PIXEL_FONT_TIERS above) is what makes a size down here still legible - a
|
// Tom Thumb (see PIXEL_FONT_TIERS above) held up down to 5px in the same real-Chromium pixel-level
|
||||||
// plain sans-serif this small would fail the old MIN_READABLE_TEXT_PX=8 floor that predates them.
|
// verification - a plain sans-serif this small would fail the old MIN_READABLE_TEXT_PX=8 floor
|
||||||
// Below this, drawTextLeaf leaves that one field blank rather than drawing illegible ink - see
|
// that predates it. Below this, drawTextLeaf leaves that one field blank rather than drawing
|
||||||
// there for why that's a quieter failure than rejecting the whole label over it.
|
// illegible ink - see there for why that's a quieter failure than rejecting the whole label over
|
||||||
|
// it.
|
||||||
const MIN_READABLE_TEXT_PX = 5;
|
const MIN_READABLE_TEXT_PX = 5;
|
||||||
|
|
||||||
function isSplit(node) {
|
function isSplit(node) {
|
||||||
|
|
@ -90,8 +140,8 @@ function parseMm(value, key) {
|
||||||
requesting the direction a split doesn't naturally combine in just inverts its own relation. */
|
requesting the direction a split doesn't naturally combine in just inverts its own relation. */
|
||||||
function relation(node, ownAxis, wantWidth, pxPerMm) {
|
function relation(node, ownAxis, wantWidth, pxPerMm) {
|
||||||
if (!isSplit(node)) {
|
if (!isSplit(node)) {
|
||||||
if (node.type === "qrcode" && node.crispSize !== undefined) {
|
if (node.type === "qrcode" && node.crispWidth !== undefined) {
|
||||||
return {a: 0, b: node.crispSize};
|
return {a: 0, b: wantWidth ? node.crispWidth : node.crispHeight};
|
||||||
}
|
}
|
||||||
if (node.type === "qrcode" || node.type === "text") {
|
if (node.type === "qrcode" || node.type === "text") {
|
||||||
const aspect = node.aspect;
|
const aspect = node.aspect;
|
||||||
|
|
@ -156,27 +206,29 @@ function positionTree(node, ownAxis, x, y) {
|
||||||
|
|
||||||
/* A QR code needs an integer number of pixels per module to render crisply rather than blurring
|
/* A QR code needs an integer number of pixels per module to render crisply rather than blurring
|
||||||
at a fractional scale, so its true size is whatever that rounds down to - almost never the
|
at a fractional scale, so its true size is whatever that rounds down to - almost never the
|
||||||
scale-free square a bare aspect ratio of 1 would suggest. Called once every qrcode leaf has a
|
scale-free box its aspect ratio alone would suggest. Called once every qrcode leaf has a
|
||||||
provisional (scale-free) box from a first layoutTree pass, this pins each one's real box.width
|
provisional (scale-free) box from a first layoutTree pass, this pins each one's real
|
||||||
(in row context) or box.height (in column context - whichever axis its box doesn't already
|
box.width/box.height as `crispWidth`/`crispHeight`, so relation() above starts treating it as a
|
||||||
share with its siblings) as `crispSize`, so relation() above starts treating it as a fixed
|
fixed size, the same as an "empty" leaf, instead of one that scales with whatever height/width
|
||||||
size, the same as an "empty" leaf, instead of one that scales with whatever height/width it's
|
it's offered. A second relation()/layoutTree() pass (see layoutContent) then resizes everything
|
||||||
offered. A second relation()/layoutTree() pass (see layoutContent) then resizes everything
|
|
||||||
else around that real footprint, so nothing downstream reserves - and leaves unfilled - room
|
else around that real footprint, so nothing downstream reserves - and leaves unfilled - room
|
||||||
for a squarer QR code than what actually gets drawn. */
|
for a squarer/differently-shaped code than what actually gets drawn. Kept as two independent
|
||||||
|
dimensions rather than one `crispSize` (as when every code here was a square QR) since an rMQR
|
||||||
|
symbol isn't square - see encodeQr. */
|
||||||
function snapQrToCrispSize(node) {
|
function snapQrToCrispSize(node) {
|
||||||
if (isSplit(node)) {
|
if (isSplit(node)) {
|
||||||
node.forEach(snapQrToCrispSize);
|
node.forEach(snapQrToCrispSize);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (node.type === "qrcode") {
|
if (node.type === "qrcode") {
|
||||||
const modules = node.qr.modules.size;
|
const {width: modulesW, height: modulesH} = node.qr;
|
||||||
const scale = Math.floor(Math.min(node.box.width, node.box.height) / modules);
|
const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH));
|
||||||
if (!(scale >= 1)) {
|
if (!(scale >= 1)) {
|
||||||
throw new Error("This text needs a bigger QR code than the tape allows — "
|
throw new Error("This text needs a bigger code than the tape allows — "
|
||||||
+ "try a shorter value or a wider tape.");
|
+ "try a shorter value or a wider tape.");
|
||||||
}
|
}
|
||||||
node.crispSize = modules * scale;
|
node.crispWidth = modulesW * scale;
|
||||||
|
node.crispHeight = modulesH * scale;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,18 +246,22 @@ function measureTextBlock(ctx, lines, referencePx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Turns a resolved content tree (see templateContent below - leaf objects carry a `value`
|
/* Turns a resolved content tree (see templateContent below - leaf objects carry a `value`
|
||||||
rather than a `content` function) into one ready for layout: a QR leaf gets its actual
|
rather than a `content` function) into one ready for layout: a QR leaf gets its actual encoded
|
||||||
QRCode.create() object, a text leaf gets its measured natural aspect ratio, and an "empty"
|
modules (see encodeQr) and an aspect ratio taken from their real width/height - 1 (square) for
|
||||||
leaf passes through untouched. Multi-line text (`value` is an array) measures as one leaf,
|
qr/micro-qr, but not for rMQR, whose symbols are rectangular - a text leaf gets its measured
|
||||||
not one per line - splitting it into a column of independently-sized leaves would let each
|
natural aspect ratio, and an "empty" leaf passes through untouched. Multi-line text (`value` is
|
||||||
line grow to its own full width, ending up at a different font size than its neighbors, which
|
an array) measures as one leaf, not one per line - splitting it into a column of independently-
|
||||||
is legible but not what "one text field" should look like. */
|
sized leaves would let each line grow to its own full width, ending up at a different font size
|
||||||
function buildRenderTree(ctx, node, referencePx) {
|
than its neighbors, which is legible but not what "one text field" should look like. `codeType`
|
||||||
|
is Print.vue's global qr/micro-qr/rmqr choice - see QR_CODE_TYPES - applied to every qrcode leaf
|
||||||
|
in the tree alike, the same way `orientation` applies to the whole tree in layoutContent. */
|
||||||
|
function buildRenderTree(ctx, node, referencePx, codeType) {
|
||||||
if (isSplit(node)) {
|
if (isSplit(node)) {
|
||||||
return node.map(child => buildRenderTree(ctx, child, referencePx));
|
return node.map(child => buildRenderTree(ctx, child, referencePx, codeType));
|
||||||
}
|
}
|
||||||
if (node.type === "qrcode") {
|
if (node.type === "qrcode") {
|
||||||
return {type: "qrcode", aspect: QR_ASPECT, qr: QRCode.create(node.value)};
|
const qr = encodeQr(node.value, codeType);
|
||||||
|
return {type: "qrcode", aspect: qr.width / qr.height, qr};
|
||||||
}
|
}
|
||||||
if (node.type === "text") {
|
if (node.type === "text") {
|
||||||
const lines = Array.isArray(node.value) ? node.value : [node.value];
|
const lines = Array.isArray(node.value) ? node.value : [node.value];
|
||||||
|
|
@ -216,22 +272,24 @@ function buildRenderTree(ctx, node, referencePx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawQrLeaf(ctx, node) {
|
function drawQrLeaf(ctx, node) {
|
||||||
const modules = node.qr.modules.size;
|
const {width: modulesW, height: modulesH} = node.qr;
|
||||||
const scale = Math.floor(Math.min(node.box.width, node.box.height) / modules);
|
const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH));
|
||||||
const size = modules * scale;
|
const w = modulesW * scale;
|
||||||
const left = node.box.x + Math.floor((node.box.width - size) / 2);
|
const h = modulesH * scale;
|
||||||
const top = node.box.y + Math.floor((node.box.height - size) / 2);
|
const left = node.box.x + Math.floor((node.box.width - w) / 2);
|
||||||
for (let row = 0; row < modules; row++) {
|
const top = node.box.y + Math.floor((node.box.height - h) / 2);
|
||||||
for (let col = 0; col < modules; col++) {
|
for (let row = 0; row < modulesH; row++) {
|
||||||
if (node.qr.modules.get(row, col)) {
|
for (let col = 0; col < modulesW; col++) {
|
||||||
|
if (node.qr.get(row, col)) {
|
||||||
ctx.fillRect(left + col * scale, top + row * scale, scale, scale);
|
ctx.fillRect(left + col * scale, top + row * scale, scale, scale);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the effective font size drawn at (or that would have been, if it's too small to draw -
|
// Returns the effective (raw, un-normalized) font size drawn at - or that would have been, if
|
||||||
// see below) - drawTree collects these into drawLabel/drawFallbackLabel's textSizesPx.
|
// it's too small to draw, see below - drawTree collects these into drawLabel/drawFallbackLabel's
|
||||||
|
// textSizesPx.
|
||||||
function drawTextLeaf(ctx, node, referencePx) {
|
function drawTextLeaf(ctx, node, referencePx) {
|
||||||
const fontPx = referencePx * (node.box.height / node.naturalHeight);
|
const fontPx = referencePx * (node.box.height / node.naturalHeight);
|
||||||
// Even the smallest PIXEL_FONT_TIERS entry stops being legible below this - rather than
|
// Even the smallest PIXEL_FONT_TIERS entry stops being legible below this - rather than
|
||||||
|
|
@ -240,27 +298,51 @@ function drawTextLeaf(ctx, node, referencePx) {
|
||||||
if (fontPx < MIN_READABLE_TEXT_PX) {
|
if (fontPx < MIN_READABLE_TEXT_PX) {
|
||||||
return fontPx;
|
return fontPx;
|
||||||
}
|
}
|
||||||
const lineHeight = node.box.height / node.lines.length;
|
const {family, scale = 1} = fontFamilyFor(fontPx);
|
||||||
const family = fontFamilyFor(fontPx);
|
const isPixelFont = family !== "sans-serif";
|
||||||
ctx.font = `${fontPx}px "${family}"`;
|
// A pixel font's glyphs are meant to land exactly on the pixel grid - node.box.x/y are
|
||||||
|
// ordinary layout math (sums/quotients of affine-solved sizes) and essentially never land on
|
||||||
|
// a whole pixel, so drawing at their exact fractional size/position would misalign a pixel
|
||||||
|
// font's 1px-wide strokes the same as it would any other font. Snapping size and position to
|
||||||
|
// the nearest whole pixel fixes that; sans-serif is left at its exact fractional fit, since
|
||||||
|
// ordinary anti-aliased text is expected to (and looks fine) regardless of position.
|
||||||
|
const snap = isPixelFont ? Math.round : (v) => v;
|
||||||
|
const drawFontPx = snap(fontPx);
|
||||||
|
// `scale` (Tom Thumb only, see PIXEL_FONT_TIERS above) corrects for a font whose declared
|
||||||
|
// size doesn't match its real visible ink - the size actually handed to ctx.font, not
|
||||||
|
// drawFontPx itself, which stays the logical size everything else here (box centering, line
|
||||||
|
// stacking) is measured against.
|
||||||
|
ctx.font = `${drawFontPx * scale}px "${family}"`;
|
||||||
// A @font-face family already in use elsewhere on the page loads in time for this, but canvas
|
// A @font-face family already in use elsewhere on the page loads in time for this, but canvas
|
||||||
// text silently falls back to the next font in the stack (there isn't one here, so the
|
// text silently falls back to the next font in the stack (there isn't one here, so the
|
||||||
// browser default) if drawn before its first-ever load finishes - unlike DOM text, a canvas
|
// browser default) if drawn before its first-ever load finishes - unlike DOM text, a canvas
|
||||||
// fillText never waits or repaints on its own once the real font arrives. Kicking off the load
|
// fillText never waits or repaints on its own once the real font arrives. Kicking off the load
|
||||||
// here means only that very first draw at a given size risks the fallback; every redraw after
|
// here means only that very first draw at a given size risks the fallback; every redraw after
|
||||||
// it (Print.vue's live preview redraws on every keystroke) picks up the real font.
|
// it (Print.vue's live preview redraws on every keystroke) picks up the real font.
|
||||||
if (family !== "sans-serif") {
|
if (isPixelFont) {
|
||||||
document.fonts.load(ctx.font);
|
document.fonts.load(ctx.font);
|
||||||
}
|
}
|
||||||
ctx.textBaseline = "middle";
|
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
const centerX = node.box.x + node.box.width / 2;
|
const centerX = snap(node.box.x + node.box.width / 2);
|
||||||
|
const lineHeight = node.box.height / node.lines.length;
|
||||||
// Lines stack as a block, each centered under the last - keeps a multi-line field reading as
|
// Lines stack as a block, each centered under the last - keeps a multi-line field reading as
|
||||||
// one unit rather than drifting apart.
|
// one unit rather than drifting apart.
|
||||||
let y = node.box.y + lineHeight / 2;
|
let sliceTop = node.box.y;
|
||||||
for (const line of node.lines) {
|
for (const line of node.lines) {
|
||||||
ctx.fillText(line, centerX, y);
|
if (isPixelFont) {
|
||||||
y += lineHeight;
|
// textBaseline:"middle" centers on the font's *declared* ascent/descent (its line
|
||||||
|
// height) - Tom Thumb's are backwards (see PIXEL_FONT_TIERS above) and would center on
|
||||||
|
// nonsense. Centering on actualBoundingBox{Ascent,Descent} instead - this specific
|
||||||
|
// string's real rendered ink (its character height) - costs nothing and stays correct
|
||||||
|
// regardless of whether a pixel font's declared metrics can be trusted.
|
||||||
|
ctx.textBaseline = "alphabetic";
|
||||||
|
const {actualBoundingBoxAscent: up, actualBoundingBoxDescent: down} = ctx.measureText(line);
|
||||||
|
ctx.fillText(line, centerX, snap(sliceTop + (lineHeight + up - down) / 2));
|
||||||
|
} else {
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText(line, centerX, sliceTop + lineHeight / 2);
|
||||||
|
}
|
||||||
|
sliceTop += lineHeight;
|
||||||
}
|
}
|
||||||
return fontPx;
|
return fontPx;
|
||||||
}
|
}
|
||||||
|
|
@ -301,61 +383,98 @@ function drawTree(ctx, node, referencePx, textSizesPx) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Builds, sizes and validates the tree for a fixed `height` (the tape's cross-web printAreaPx, or
|
/* Builds, sizes and validates the tree for a fixed `fixedSize` (the tape's cross-web printAreaPx,
|
||||||
the fallback preview's reference height) - the one dimension every layout scales from, plus
|
or the fallback preview's reference height) - the one dimension every layout scales from, plus
|
||||||
`pxPerMm` to turn "empty" leaves' physical sizes into pixels. `height` and the tree's content
|
`pxPerMm` to turn "empty" leaves' physical sizes into pixels. `fixedSize` and the tree's content
|
||||||
fully determine its overall width; `maxLength`, when finite (a fixed-length/die-cut tape),
|
fully determine its overall size along the other, growing axis (the one that runs along the
|
||||||
rejects content that doesn't fit rather than shrinking it.
|
tape as it feeds); `maxLength`, when finite (a fixed-length/die-cut tape), rejects content that
|
||||||
|
doesn't fit rather than shrinking it.
|
||||||
|
|
||||||
Sizing runs twice: a first pass treats every qrcode leaf as the scale-free square its aspect
|
`orientation` picks which axis `fixedSize` binds to: "along" (the default) fixes the tree's
|
||||||
ratio of 1 suggests, purely to find out how much room each one would actually be offered; from
|
height - the tape's cross-web width - and grows its width along the feed direction, same as a
|
||||||
that, snapQrToCrispSize pins each one's real (smaller, crisp-pixel) size. The second pass then
|
plain read top-to-bottom design. "across" fixes the tree's width instead and grows its height,
|
||||||
resolves the whole tree again with that real size fixed in, so every sibling and the overall
|
so the design is built turned 90deg from how it'd read "along" - drawLabel/drawFallbackLabel
|
||||||
width reflect what's actually drawn rather than the idealized square no QR code ever quite
|
are what actually rotate the drawing back into the physical raster's fixed orientation; nothing
|
||||||
fills. */
|
here needs to know about that rotation, since relation()/layoutTree() below already solve the
|
||||||
function layoutContent(ctx, content, height, maxLength, referencePx, pxPerMm) {
|
tree in either direction symmetrically.
|
||||||
const tree = buildRenderTree(ctx, content, referencePx);
|
|
||||||
|
|
||||||
const measured = relation(tree, false, true, pxPerMm);
|
Sizing runs twice: a first pass treats every qrcode leaf as the scale-free box its real
|
||||||
layoutTree(tree, false, measured.a * height + measured.b, height, pxPerMm);
|
width/height ratio suggests, purely to find out how much room each one would actually be
|
||||||
|
offered; from that, snapQrToCrispSize pins each one's real (smaller, crisp-pixel) size. The
|
||||||
|
second pass then resolves the whole tree again with that real size fixed in, so every sibling
|
||||||
|
and the overall size reflect what's actually drawn rather than the idealized box no code ever
|
||||||
|
quite fills. `codeType`, see buildRenderTree. */
|
||||||
|
function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm, orientation, codeType) {
|
||||||
|
const tree = buildRenderTree(ctx, content, referencePx, codeType);
|
||||||
|
const alongTape = orientation !== "across";
|
||||||
|
|
||||||
|
const solve = () => {
|
||||||
|
const {a, b} = relation(tree, false, alongTape, pxPerMm);
|
||||||
|
return alongTape
|
||||||
|
? {width: a * fixedSize + b, height: fixedSize}
|
||||||
|
: {width: fixedSize, height: a * fixedSize + b};
|
||||||
|
};
|
||||||
|
|
||||||
|
let {width, height} = solve();
|
||||||
|
layoutTree(tree, false, width, height, pxPerMm);
|
||||||
snapQrToCrispSize(tree);
|
snapQrToCrispSize(tree);
|
||||||
|
|
||||||
const {a, b} = relation(tree, false, true, pxPerMm);
|
({width, height} = solve());
|
||||||
const width = a * height + b;
|
const length = alongTape ? width : height;
|
||||||
if (maxLength !== Infinity && width > maxLength) {
|
if (maxLength !== Infinity && length > maxLength) {
|
||||||
throw new Error("This doesn't fit on this tape — "
|
throw new Error("This doesn't fit on this tape — "
|
||||||
+ "try a shorter value, a different layout, or a bigger label.");
|
+ "try a shorter value, a different layout, or a bigger label.");
|
||||||
}
|
}
|
||||||
layoutTree(tree, false, width, height, pxPerMm);
|
layoutTree(tree, false, width, height, pxPerMm);
|
||||||
return {tree, width};
|
return {tree, length};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The tape-fed layout - draws a fully resolved content tree (see templateContent) at the tape's
|
/* The tape-fed layout - draws a fully resolved content tree (see templateContent) at the tape's
|
||||||
real pixel dimensions. See DEBUG_LEAF_BORDERS above to outline every leaf's box. Returns
|
real pixel dimensions. `orientation` is "along" (the default) to lay the design out reading
|
||||||
|
along the tape's feed direction, or "across" to turn it 90deg so it reads across the tape
|
||||||
|
instead - either way the physical raster this returns is still exactly
|
||||||
|
printedLength x tape.printAreaPx (that's fixed by the tape/print head, not a choice this
|
||||||
|
makes); "across" just draws the (now width-fixed, see layoutContent) tree through a rotated
|
||||||
|
canvas transform so it lands correctly in that same raster, rather than transposing every box
|
||||||
|
the tree itself computed. See DEBUG_LEAF_BORDERS above to outline every leaf's box. Returns
|
||||||
{textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right,
|
{textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right,
|
||||||
top-to-bottom order. */
|
top-to-bottom order. `codeType` (default "qr"), see buildRenderTree/QR_CODE_TYPES. */
|
||||||
export function drawLabel(canvas, tape, content) {
|
export function drawLabel(canvas, tape, content, orientation = "along", codeType = "qr") {
|
||||||
const maxLength = tape.printLengthPx
|
const maxLength = tape.printLengthPx
|
||||||
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
|
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
|
||||||
: Infinity;
|
: Infinity;
|
||||||
const measureCtx = canvas.getContext("2d");
|
const measureCtx = canvas.getContext("2d");
|
||||||
const pxPerMm = tape.dpi / 25.4;
|
const pxPerMm = tape.dpi / 25.4;
|
||||||
const {tree, width: contentWidth} = layoutContent(
|
const {tree, length: contentLength} = layoutContent(
|
||||||
measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm);
|
measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm, orientation, codeType);
|
||||||
|
|
||||||
const width = tape.printLengthPx || Math.ceil(contentWidth + tape.leadPx + TRAILING_PADDING_PX);
|
const printedLength = tape.printLengthPx || Math.ceil(contentLength + tape.leadPx + TRAILING_PADDING_PX);
|
||||||
canvas.width = width;
|
canvas.width = printedLength;
|
||||||
canvas.height = tape.printAreaPx;
|
canvas.height = tape.printAreaPx;
|
||||||
|
|
||||||
const ctx = canvas.getContext("2d", {willReadFrequently: true});
|
const ctx = canvas.getContext("2d", {willReadFrequently: true});
|
||||||
ctx.fillStyle = "#fff";
|
ctx.fillStyle = "#fff";
|
||||||
ctx.fillRect(0, 0, width, canvas.height);
|
ctx.fillRect(0, 0, printedLength, canvas.height);
|
||||||
ctx.fillStyle = "#000";
|
ctx.fillStyle = "#000";
|
||||||
|
|
||||||
const originX = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - contentWidth) / 2);
|
const origin = tape.leadPx
|
||||||
positionTree(tree, false, originX, 0);
|
+ Math.floor((printedLength - tape.leadPx - TRAILING_PADDING_PX - contentLength) / 2);
|
||||||
const textSizesPx = [];
|
const textSizesPx = [];
|
||||||
|
if (orientation === "across") {
|
||||||
|
// The tree was solved width-fixed (see layoutContent) - its width already exactly fills
|
||||||
|
// tape.printAreaPx, so only its (growing) height needs the same along-the-feed centering
|
||||||
|
// originX got above; translate+rotate then carries that tree-local (x, y) box straight
|
||||||
|
// into the physical (printedLength x printAreaPx) raster, a quarter turn at a time.
|
||||||
|
positionTree(tree, false, 0, origin);
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(0, tape.printAreaPx);
|
||||||
|
ctx.rotate(-Math.PI / 2);
|
||||||
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
||||||
|
ctx.restore();
|
||||||
|
} else {
|
||||||
|
positionTree(tree, false, origin, 0);
|
||||||
|
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
||||||
|
}
|
||||||
return {textSizesPx};
|
return {textSizesPx};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -365,14 +484,15 @@ const FALLBACK_DPI = 203; /* reference resolution for turning "empty" leaves' m
|
||||||
/* The no-webusb preview/PNG - same layout tree and renderer as drawLabel, just scaled from a
|
/* The no-webusb preview/PNG - same layout tree and renderer as drawLabel, just scaled from a
|
||||||
fixed reference height instead of a real tape's, and with no maxLength (there's no physical
|
fixed reference height instead of a real tape's, and with no maxLength (there's no physical
|
||||||
tape to run out of, so the canvas just grows to fit) and no printer feed margin, since there's
|
tape to run out of, so the canvas just grows to fit) and no printer feed margin, since there's
|
||||||
no real print head here to keep clear of. Returns {textSizesPx}, see drawLabel. */
|
no real print head here to keep clear of. `orientation`/`codeType`, see drawLabel. Returns
|
||||||
export function drawFallbackLabel(canvas, content) {
|
{textSizesPx}, see drawLabel. */
|
||||||
|
export function drawFallbackLabel(canvas, content, orientation = "along", codeType = "qr") {
|
||||||
const measureCtx = canvas.getContext("2d");
|
const measureCtx = canvas.getContext("2d");
|
||||||
const pxPerMm = FALLBACK_DPI / 25.4;
|
const pxPerMm = FALLBACK_DPI / 25.4;
|
||||||
const {tree, width: contentWidth} = layoutContent(
|
const {tree, length: contentLength} = layoutContent(
|
||||||
measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm);
|
measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm, orientation, codeType);
|
||||||
|
|
||||||
canvas.width = Math.ceil(contentWidth);
|
canvas.width = Math.ceil(contentLength);
|
||||||
canvas.height = FALLBACK_LABEL_HEIGHT_PX;
|
canvas.height = FALLBACK_LABEL_HEIGHT_PX;
|
||||||
|
|
||||||
const ctx = canvas.getContext("2d", {willReadFrequently: true});
|
const ctx = canvas.getContext("2d", {willReadFrequently: true});
|
||||||
|
|
@ -380,9 +500,18 @@ export function drawFallbackLabel(canvas, content) {
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
ctx.fillStyle = "#000";
|
ctx.fillStyle = "#000";
|
||||||
|
|
||||||
positionTree(tree, false, 0, 0);
|
|
||||||
const textSizesPx = [];
|
const textSizesPx = [];
|
||||||
|
if (orientation === "across") {
|
||||||
|
positionTree(tree, false, 0, 0);
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(0, FALLBACK_LABEL_HEIGHT_PX);
|
||||||
|
ctx.rotate(-Math.PI / 2);
|
||||||
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
||||||
|
ctx.restore();
|
||||||
|
} else {
|
||||||
|
positionTree(tree, false, 0, 0);
|
||||||
|
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
||||||
|
}
|
||||||
return {textSizesPx};
|
return {textSizesPx};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,10 @@ import Admin from '@/views/Admin.vue';
|
||||||
import Swatch from '@/views/Swatch.vue';
|
import Swatch from '@/views/Swatch.vue';
|
||||||
import Files from '@/views/Files.vue';
|
import Files from '@/views/Files.vue';
|
||||||
import Print from "@/views/Print.vue";
|
import Print from "@/views/Print.vue";
|
||||||
|
import Scan from "@/views/Scan.vue";
|
||||||
import Workflows from '@/views/Workflows.vue';
|
import Workflows from '@/views/Workflows.vue';
|
||||||
import WorkflowDetail from '@/views/WorkflowDetail.vue';
|
import WorkflowDetail from '@/views/WorkflowDetail.vue';
|
||||||
|
import ShortId from '@/views/ShortId.vue';
|
||||||
|
|
||||||
import Account from '@/views/settings/Account.vue';
|
import Account from '@/views/settings/Account.vue';
|
||||||
import Password from '@/views/settings/Password.vue';
|
import Password from '@/views/settings/Password.vue';
|
||||||
|
|
@ -36,6 +38,14 @@ import Data from '@/views/settings/Data.vue';
|
||||||
import Preferences from '@/views/settings/Preferences.vue';
|
import Preferences from '@/views/settings/Preferences.vue';
|
||||||
|
|
||||||
|
|
||||||
|
export function encodeHandleForUrl(handle) {
|
||||||
|
return handle.replace(/#/g, "+");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeHandleFromUrl(segment) {
|
||||||
|
return segment.replace(/\+/g, "#");
|
||||||
|
}
|
||||||
|
|
||||||
const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
path: '/inventory',
|
path: '/inventory',
|
||||||
component: Inventory,
|
component: Inventory,
|
||||||
|
|
@ -58,8 +68,9 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
}, {
|
}, {
|
||||||
path: '/i/:handle/:id',
|
path: '/i/:handle/:id',
|
||||||
redirect: to => {
|
redirect: to => {
|
||||||
const {handle, id} = to.params
|
const handle = decodeHandleFromUrl(to.params.handle)
|
||||||
return handle === store.state.user ? '/inventory/' + id : '/inventory/shared/' + handle + '/' + id
|
const {id} = to.params
|
||||||
|
return handle === store.state.user ? '/inventory/' + id : '/inventory/shared/' + encodeHandleForUrl(handle) + '/' + id
|
||||||
}
|
}
|
||||||
}, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
|
}, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
|
||||||
path: '/friends',
|
path: '/friends',
|
||||||
|
|
@ -98,6 +109,10 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
const {kind, ...components} = route.query;
|
const {kind, ...components} = route.query;
|
||||||
return {prefill: kind ? {kind, components} : null};
|
return {prefill: kind ? {kind, components} : null};
|
||||||
}
|
}
|
||||||
|
}, {
|
||||||
|
path: '/scan',
|
||||||
|
component: Scan,
|
||||||
|
meta: {requiresAuth: true},
|
||||||
}, {
|
}, {
|
||||||
path: '/search/:query',
|
path: '/search/:query',
|
||||||
component: Search,
|
component: Search,
|
||||||
|
|
@ -145,6 +160,10 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
path: '/storage-locations/new',
|
path: '/storage-locations/new',
|
||||||
component: StorageLocationNew,
|
component: StorageLocationNew,
|
||||||
meta: {requiresAuth: true}
|
meta: {requiresAuth: true}
|
||||||
|
}, {
|
||||||
|
path: '/:short_id',
|
||||||
|
component: ShortId,
|
||||||
|
props: true
|
||||||
}, {path: '/:pathMatch(.*)*', redirect: '/'}]
|
}, {path: '/:pathMatch(.*)*', redirect: '/'}]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|
|
||||||
217
frontend/src/short-id.js
Normal file
217
frontend/src/short-id.js
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
const KIND_TAG_BITS = 2
|
||||||
|
const CHUNK_BITS = 4
|
||||||
|
|
||||||
|
|
||||||
|
const SCHEMAS = {
|
||||||
|
'0-2': {
|
||||||
|
name: 'item',
|
||||||
|
fields: ['owner_identity_id', 'item_local_id'],
|
||||||
|
interpret: ([owner_identity_id, item_local_id]) => ({kind: 'item', owner_identity_id, item_local_id})
|
||||||
|
},
|
||||||
|
'1-2': {
|
||||||
|
name: 'storage_location',
|
||||||
|
fields: ['owner_identity_id', 'storage_location_id'],
|
||||||
|
interpret: ([owner_identity_id, storage_location_id]) =>
|
||||||
|
({kind: 'storage_location', owner_identity_id, storage_location_id})
|
||||||
|
},
|
||||||
|
'2-1': {
|
||||||
|
name: 'category',
|
||||||
|
fields: ['category_id'],
|
||||||
|
interpret: ([category_id]) => ({kind: 'category', category_id})
|
||||||
|
},
|
||||||
|
'3-2': {
|
||||||
|
name: 'workflow',
|
||||||
|
fields: ['owner_identity_id', 'workflow_id'],
|
||||||
|
interpret: ([owner_identity_id, workflow_id]) => ({kind: 'workflow', owner_identity_id, workflow_id})
|
||||||
|
},
|
||||||
|
'4-1': {
|
||||||
|
name: 'group',
|
||||||
|
fields: ['group_id'],
|
||||||
|
interpret: ([group_id]) => ({kind: 'group', group_id})
|
||||||
|
},
|
||||||
|
'5-1': {
|
||||||
|
name: 'file',
|
||||||
|
fields: ['file_id'],
|
||||||
|
interpret: ([file_id]) => ({kind: 'file', file_id})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
|
||||||
|
const DIRECT_KIND_COUNT = 2 ** KIND_TAG_BITS - 1
|
||||||
|
|
||||||
|
class BitWriter {
|
||||||
|
constructor() {
|
||||||
|
this.bits = []
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBits(value, count) {
|
||||||
|
value = BigInt(value)
|
||||||
|
for (let i = count - 1; i >= 0; i--) {
|
||||||
|
this.bits.push(Number((value >> BigInt(i)) & 1n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toBase64Url() {
|
||||||
|
const padded = this.bits.slice()
|
||||||
|
while (padded.length % 6 !== 0) {
|
||||||
|
padded.push(0)
|
||||||
|
}
|
||||||
|
let token = ''
|
||||||
|
for (let i = 0; i < padded.length; i += 6) {
|
||||||
|
let n = 0
|
||||||
|
for (let j = 0; j < 6; j++) {
|
||||||
|
n = (n << 1) | padded[i + j]
|
||||||
|
}
|
||||||
|
token += BASE64URL_ALPHABET[n]
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BitReader {
|
||||||
|
constructor(bits) {
|
||||||
|
this.bits = bits
|
||||||
|
this.pos = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
readBits(count) {
|
||||||
|
let value = 0n
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
value = (value << 1n) | BigInt(this.bits[this.pos] ?? 0)
|
||||||
|
this.pos++
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlToBits(text) {
|
||||||
|
const bits = []
|
||||||
|
for (const char of text) {
|
||||||
|
const n = BASE64URL_ALPHABET.indexOf(char)
|
||||||
|
if (n === -1) {
|
||||||
|
throw new Error(`invalid short id: '${char}' is not a URL-safe base64 character`)
|
||||||
|
}
|
||||||
|
for (let i = 5; i >= 0; i--) {
|
||||||
|
bits.push((n >> i) & 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bits
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeChunkedInt(writer, value, chunkBits) {
|
||||||
|
value = BigInt(value)
|
||||||
|
if (value < 0n) {
|
||||||
|
throw new Error('short id values must be non-negative integers')
|
||||||
|
}
|
||||||
|
const mask = (1n << BigInt(chunkBits)) - 1n
|
||||||
|
const chunks = []
|
||||||
|
let remaining = value
|
||||||
|
do {
|
||||||
|
chunks.unshift(remaining & mask)
|
||||||
|
remaining >>= BigInt(chunkBits)
|
||||||
|
} while (remaining > 0n)
|
||||||
|
chunks.forEach((chunk, i) => {
|
||||||
|
writer.writeBits(i === chunks.length - 1 ? 0 : 1, 1)
|
||||||
|
writer.writeBits(chunk, chunkBits)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function readChunkedInt(reader, chunkBits) {
|
||||||
|
let value = 0n
|
||||||
|
for (; ;) {
|
||||||
|
const more = reader.readBits(1)
|
||||||
|
const data = reader.readBits(chunkBits)
|
||||||
|
value = (value << BigInt(chunkBits)) | data
|
||||||
|
if (more === 0n) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function entriesForKindIndex(kindIndex) {
|
||||||
|
return Object.entries(SCHEMAS).filter(([key]) => key.startsWith(`${kindIndex}-`))
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaForKindIndex(kindIndex) {
|
||||||
|
const matches = entriesForKindIndex(kindIndex)
|
||||||
|
if (matches.length === 0) {
|
||||||
|
throw new Error(`unknown short id kind index: ${kindIndex}`)
|
||||||
|
}
|
||||||
|
if (matches.length > 1) {
|
||||||
|
throw new Error(
|
||||||
|
`ambiguous short id kind index ${kindIndex}: base-level decoding needs exactly one `
|
||||||
|
+ `field count per kind, found arities ${matches.map(([, s]) => s.fields.length).join(', ')} `
|
||||||
|
+ `- only deserializeShortId's (kind, fields.length) lookup can tell those apart`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return matches[0][1]
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaForKindAndArity(kindIndex, arity) {
|
||||||
|
const schema = SCHEMAS[`${kindIndex}-${arity}`]
|
||||||
|
if (!schema) {
|
||||||
|
throw new Error(`unknown short id schema for kind ${kindIndex} with ${arity} field value(s)`)
|
||||||
|
}
|
||||||
|
return schema
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeShortId(ints) {
|
||||||
|
if (!Array.isArray(ints) || ints.length === 0) {
|
||||||
|
throw new Error('short id must be a non-empty list of [kindIndex, ...fieldValues]')
|
||||||
|
}
|
||||||
|
const [kindIndex, ...fieldValues] = ints
|
||||||
|
const schema = schemaForKindIndex(kindIndex)
|
||||||
|
if (fieldValues.length !== schema.fields.length) {
|
||||||
|
throw new Error(
|
||||||
|
`short id kind index ${kindIndex} ('${schema.name}') needs ${schema.fields.length} field value(s), got ${fieldValues.length}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const writer = new BitWriter()
|
||||||
|
if (kindIndex < DIRECT_KIND_COUNT) {
|
||||||
|
writer.writeBits(kindIndex, KIND_TAG_BITS)
|
||||||
|
} else {
|
||||||
|
writer.writeBits(DIRECT_KIND_COUNT, KIND_TAG_BITS)
|
||||||
|
writeChunkedInt(writer, kindIndex - DIRECT_KIND_COUNT, CHUNK_BITS)
|
||||||
|
}
|
||||||
|
for (const value of fieldValues) {
|
||||||
|
writeChunkedInt(writer, value, CHUNK_BITS)
|
||||||
|
}
|
||||||
|
return '~' + writer.toBase64Url()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeShortId(token) {
|
||||||
|
if (typeof token !== 'string' || !token.startsWith('~')) {
|
||||||
|
throw new Error("short id must start with '~'")
|
||||||
|
}
|
||||||
|
const reader = new BitReader(base64UrlToBits(token.slice(1)))
|
||||||
|
let kindIndex = Number(reader.readBits(KIND_TAG_BITS))
|
||||||
|
if (kindIndex === DIRECT_KIND_COUNT) {
|
||||||
|
kindIndex = DIRECT_KIND_COUNT + Number(readChunkedInt(reader, CHUNK_BITS))
|
||||||
|
}
|
||||||
|
const schema = schemaForKindIndex(kindIndex)
|
||||||
|
const fieldValues = schema.fields.map(() => Number(readChunkedInt(reader, CHUNK_BITS)))
|
||||||
|
return [kindIndex, ...fieldValues]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeShortId(ints) {
|
||||||
|
const [kindIndex, ...fieldValues] = ints
|
||||||
|
const schema = schemaForKindAndArity(kindIndex, fieldValues.length)
|
||||||
|
return schema.interpret(fieldValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeShortId({kind, ...fieldValues}) {
|
||||||
|
const entry = Object.entries(SCHEMAS).find(([, s]) => s.name === kind)
|
||||||
|
if (!entry) {
|
||||||
|
throw new Error(`unknown short id kind: ${kind}`)
|
||||||
|
}
|
||||||
|
const [key, schema] = entry
|
||||||
|
const kindIndex = Number(key.split('-')[0])
|
||||||
|
const values = schema.fields.map(name => {
|
||||||
|
const value = fieldValues[name]
|
||||||
|
if (value === undefined) {
|
||||||
|
throw new Error(`missing field '${name}' for short id kind '${kind}'`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
return [kindIndex, ...values]
|
||||||
|
}
|
||||||
104
frontend/src/tests/short-id.js
Normal file
104
frontend/src/tests/short-id.js
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import {encodeShortId, decodeShortId, deserializeShortId, serializeShortId} from '../short-id.js'
|
||||||
|
|
||||||
|
test('encodes the worked example from docs/handles-and-shortids.md', () => {
|
||||||
|
const token = encodeShortId([0, 7, 42])
|
||||||
|
expect(token).toBe('~DyU')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('round-trips every kind as a flat [kindIndex, ...fieldValues] list', () => {
|
||||||
|
const cases = [
|
||||||
|
[0, 7, 42],
|
||||||
|
[1, 3, 1000],
|
||||||
|
[2, 0],
|
||||||
|
]
|
||||||
|
for (const ints of cases) {
|
||||||
|
const token = encodeShortId(ints)
|
||||||
|
expect(decodeShortId(token)).toEqual(ints)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('round-trips small and large values, including zero', () => {
|
||||||
|
const values = [0, 1, 7, 42, 100, 10000, 100000, 123456789]
|
||||||
|
for (const value of values) {
|
||||||
|
const token = encodeShortId([2, value])
|
||||||
|
expect(decodeShortId(token)).toEqual([2, value])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('tokens are URL-safe: only unreserved characters, plus the leading ~', () => {
|
||||||
|
const token = encodeShortId([0, 123456, 987654])
|
||||||
|
expect(token[0]).toBe('~')
|
||||||
|
expect(token.slice(1)).toMatch(/^[A-Za-z0-9\-_]+$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deserializeShortId names a decoded list into a flat shape, serializeShortId reverses it', () => {
|
||||||
|
const cases = [
|
||||||
|
[[0, 7, 42], {kind: 'item', owner_identity_id: 7, item_local_id: 42}],
|
||||||
|
[[1, 3, 1000], {kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000}],
|
||||||
|
[[2, 0], {kind: 'category', category_id: 0}],
|
||||||
|
[[3, 2, 9], {kind: 'workflow', owner_identity_id: 2, workflow_id: 9}],
|
||||||
|
[[4, 11], {kind: 'group', group_id: 11}],
|
||||||
|
[[5, 123], {kind: 'file', file_id: 123}],
|
||||||
|
]
|
||||||
|
for (const [ints, named] of cases) {
|
||||||
|
expect(deserializeShortId(ints)).toEqual(named)
|
||||||
|
expect(serializeShortId(named)).toEqual(ints)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('kinds 3+ round-trip through the escape tag, not just the direct 0-2 range', () => {
|
||||||
|
for (const named of [
|
||||||
|
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
|
||||||
|
{kind: 'group', group_id: 11},
|
||||||
|
{kind: 'file', file_id: 123},
|
||||||
|
]) {
|
||||||
|
const token = encodeShortId(serializeShortId(named))
|
||||||
|
expect(deserializeShortId(decodeShortId(token))).toEqual(named)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('serializeShortId then deserializeShortId round-trips through encode/decode', () => {
|
||||||
|
const token = encodeShortId(serializeShortId({kind: 'item', owner_identity_id: 7, item_local_id: 42}))
|
||||||
|
expect(token).toBe('~DyU')
|
||||||
|
expect(deserializeShortId(decodeShortId(token))).toEqual({
|
||||||
|
kind: 'item',
|
||||||
|
owner_identity_id: 7,
|
||||||
|
item_local_id: 42
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects a token missing the leading ~', () => {
|
||||||
|
expect(() => decodeShortId('DyU')).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects an unknown kind index', () => {
|
||||||
|
expect(() => encodeShortId([99, 1])).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deserializeShortId is keyed by (kind, fields.length), not kind alone', () => {
|
||||||
|
// kind=1 with the arity storage_location actually has (2 fields) is fine...
|
||||||
|
expect(deserializeShortId([1, 3, 1000])).toEqual({
|
||||||
|
kind: 'storage_location',
|
||||||
|
owner_identity_id: 3,
|
||||||
|
storage_location_id: 1000
|
||||||
|
})
|
||||||
|
// ...but kind=1 with a different arity (1 field) isn't a registered schema at all, and must
|
||||||
|
// not be silently misread as a truncated storage_location.
|
||||||
|
expect(() => deserializeShortId([1, 359])).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects an unknown kind name', () => {
|
||||||
|
expect(() => serializeShortId({kind: 'not_a_kind'})).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects a field count mismatch', () => {
|
||||||
|
expect(() => encodeShortId([0, 7])).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects a missing field', () => {
|
||||||
|
expect(() => serializeShortId({kind: 'item', owner_identity_id: 7})).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects a negative field value', () => {
|
||||||
|
expect(() => encodeShortId([2, -1])).toThrow()
|
||||||
|
})
|
||||||
|
|
@ -46,6 +46,35 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--div class="card mb-3 border-warning">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0">Experimental: new font candidates</h5>
|
||||||
|
<small class="text-muted">for judging legibility - not part of the real label pipeline</small>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Nine pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text"
|
||||||
|
field above at a range of sizes so they can be judged the same way Tom Thumb/Silkscreen
|
||||||
|
were. <strong>CodersCrux</strong> and <strong>712Serif</strong> fail to load as web
|
||||||
|
fonts at all - Chromium's OTS sanitizer rejects their cmap table - so their cells below
|
||||||
|
are actually just the browser's fallback serif, not a rendering bug in this page.
|
||||||
|
<strong>Chava</strong> is uppercase-only, same dealbreaker as PICO-8.
|
||||||
|
<strong>6pxExpert</strong> only has pixel glyphs for digits; its letters are the same
|
||||||
|
fallback serif.
|
||||||
|
</p>
|
||||||
|
<div v-for="cfg in candidateFonts" :key="cfg.family" class="mb-3">
|
||||||
|
<div class="fw-bold small mb-1">{{ cfg.family }}</div>
|
||||||
|
<div class="d-flex flex-wrap gap-3">
|
||||||
|
<div v-for="size in candidateSizes" :key="cfg.family + '-' + size" class="text-center">
|
||||||
|
<div class="small text-muted mb-1">{{ size }}px</div>
|
||||||
|
<canvas :ref="el => setCandidateCanvasRef(cfg.family + '-' + size, el)"
|
||||||
|
class="pixel-test-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div-->
|
||||||
|
|
||||||
<div v-if="!usbSupported" class="row">
|
<div v-if="!usbSupported" class="row">
|
||||||
<div class="col-lg-7">
|
<div class="col-lg-7">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -57,6 +86,37 @@
|
||||||
<canvas ref="fallbackCanvas"></canvas>
|
<canvas ref="fallbackCanvas"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Orientation</label>
|
||||||
|
<div class="btn-group d-block" role="group">
|
||||||
|
<input type="radio" class="btn-check" id="orientation-along-fallback"
|
||||||
|
autocomplete="off" value="along" v-model="orientation">
|
||||||
|
<label class="btn btn-outline-secondary" for="orientation-along-fallback">
|
||||||
|
Along tape
|
||||||
|
</label>
|
||||||
|
<input type="radio" class="btn-check" id="orientation-across-fallback"
|
||||||
|
autocomplete="off" value="across" v-model="orientation">
|
||||||
|
<label class="btn btn-outline-secondary" for="orientation-across-fallback">
|
||||||
|
Across tape
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Code type</label>
|
||||||
|
<div class="btn-group d-block" role="group">
|
||||||
|
<template v-for="opt in codeTypeOptions" :key="opt.id">
|
||||||
|
<input type="radio" class="btn-check"
|
||||||
|
:id="'code-type-' + opt.id + '-fallback'"
|
||||||
|
autocomplete="off" :value="opt.id" v-model="codeType">
|
||||||
|
<label class="btn btn-outline-secondary"
|
||||||
|
:for="'code-type-' + opt.id + '-fallback'">
|
||||||
|
{{ opt.label }}
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-primary" :disabled="!fallbackReady" @click="downloadPng">
|
<button class="btn btn-primary" :disabled="!fallbackReady" @click="downloadPng">
|
||||||
<b-icon-download class="me-1"></b-icon-download>
|
<b-icon-download class="me-1"></b-icon-download>
|
||||||
Download label as PNG
|
Download label as PNG
|
||||||
|
|
@ -132,6 +192,34 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-2 align-items-end">
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label">Orientation</label>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<input type="radio" class="btn-check" id="orientation-along"
|
||||||
|
autocomplete="off" value="along" v-model="orientation">
|
||||||
|
<label class="btn btn-outline-secondary" for="orientation-along">
|
||||||
|
Along tape
|
||||||
|
</label>
|
||||||
|
<input type="radio" class="btn-check" id="orientation-across"
|
||||||
|
autocomplete="off" value="across" v-model="orientation">
|
||||||
|
<label class="btn btn-outline-secondary" for="orientation-across">
|
||||||
|
Across tape
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label">Code type</label>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<template v-for="opt in codeTypeOptions" :key="opt.id">
|
||||||
|
<input type="radio" class="btn-check"
|
||||||
|
:id="'code-type-' + opt.id" autocomplete="off"
|
||||||
|
:value="opt.id" v-model="codeType">
|
||||||
|
<label class="btn btn-outline-secondary" :for="'code-type-' + opt.id">
|
||||||
|
{{ opt.label }}
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label class="form-label">Copies</label>
|
<label class="form-label">Copies</label>
|
||||||
<input type="number" class="form-control copies-input"
|
<input type="number" class="form-control copies-input"
|
||||||
|
|
@ -187,7 +275,7 @@
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label-layout-preview :fields="fields" :value="selectedTemplate"
|
<label-layout-preview :fields="fields" :value="selectedTemplate" :code-type="codeType"
|
||||||
@input="selectedTemplate = $event"></label-layout-preview>
|
@input="selectedTemplate = $event"></label-layout-preview>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -203,7 +291,7 @@ import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
|
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
|
||||||
|
|
||||||
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
|
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
|
||||||
import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields} from "@/label.js";
|
import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields, preloadQrEncoder, QR_CODE_TYPES} from "@/label.js";
|
||||||
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";
|
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";
|
||||||
|
|
||||||
// Served verbatim from public/vendor/ rather than bundled: libweblabel.js's
|
// Served verbatim from public/vendor/ rather than bundled: libweblabel.js's
|
||||||
|
|
@ -226,6 +314,15 @@ const RULER_TIERS = [
|
||||||
{aboveMm: 500, tickMm: 5, majorEveryMm: 25},
|
{aboveMm: 500, tickMm: 5, majorEveryMm: 25},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Display labels for label.js's QR_CODE_TYPES - the code-type button group below reads this
|
||||||
|
// (via codeTypeOptions) rather than hard-coding its own list, so a new symbology added to
|
||||||
|
// anyd-qr.js/QR_CODE_TYPES only needs a label here, not a whole new radio group.
|
||||||
|
const CODE_TYPE_LABELS = {
|
||||||
|
"qr": "QR",
|
||||||
|
"micro-qr": "Micro QR",
|
||||||
|
"rmqr": "rMQR",
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Print",
|
name: "Print",
|
||||||
components: {
|
components: {
|
||||||
|
|
@ -283,11 +380,40 @@ export default {
|
||||||
},
|
},
|
||||||
copies: 1,
|
copies: 1,
|
||||||
selectedTemplate: LABEL_TEMPLATES[0].id,
|
selectedTemplate: LABEL_TEMPLATES[0].id,
|
||||||
|
// "along" draws a layout reading along the tape's feed direction (the usual case -
|
||||||
|
// constrained by the tape's cross-web width, growing as long as the content needs);
|
||||||
|
// "across" turns it 90deg, constrained by that same width but along the *other* axis
|
||||||
|
// instead, so it reads across the tape rather than along it. See label.js's
|
||||||
|
// drawLabel/drawFallbackLabel for how that turn is actually drawn.
|
||||||
|
orientation: "along",
|
||||||
|
// Which of label.js's QR_CODE_TYPES every "qrcode" leaf in the current template
|
||||||
|
// renders as - one global choice for the whole label, same as `orientation` above,
|
||||||
|
// since a template only ever has one scannable code in practice.
|
||||||
|
codeType: "qr",
|
||||||
|
|
||||||
fallbackReady: false,
|
fallbackReady: false,
|
||||||
// TODO: replace with the real commands for our printers.
|
// TODO: replace with the real commands for our printers.
|
||||||
brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png",
|
brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png",
|
||||||
niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",
|
niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",
|
||||||
|
|
||||||
|
// Nine pixel/bitmap-style font candidates found in frontend/public (see
|
||||||
|
// ../assets/fonts/pixel-candidates/LICENSE.md) - none of these are used by label.js's
|
||||||
|
// real PIXEL_FONT_TIERS; this card exists purely so they can be judged the same way
|
||||||
|
// Tom Thumb/Silkscreen were, against real (live-typed) content instead of a fixed
|
||||||
|
// example.
|
||||||
|
candidateFonts: [
|
||||||
|
{family: "Pixelon"},
|
||||||
|
{family: "Pixelbasel"},
|
||||||
|
{family: "Chava"},
|
||||||
|
{family: "CodersCrux"},
|
||||||
|
{family: "712Serif"},
|
||||||
|
{family: "6pxNormal"},
|
||||||
|
{family: "6pxExpert"},
|
||||||
|
{family: "Jersey10"},
|
||||||
|
{family: "Jersey15"},
|
||||||
|
],
|
||||||
|
candidateSizes: [5, 6, 7, 8, 10, 12, 16, 20],
|
||||||
|
candidateZoom: 4,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -297,6 +423,11 @@ export default {
|
||||||
baseVars() {
|
baseVars() {
|
||||||
return BASE_VARS;
|
return BASE_VARS;
|
||||||
},
|
},
|
||||||
|
// {id, label} pairs for the code-type button group (see CODE_TYPE_LABELS above), in
|
||||||
|
// label.js's QR_CODE_TYPES order.
|
||||||
|
codeTypeOptions() {
|
||||||
|
return QR_CODE_TYPES.map(id => ({id, label: CODE_TYPE_LABELS[id]}));
|
||||||
|
},
|
||||||
derivedVars() {
|
derivedVars() {
|
||||||
return Object.keys(DERIVED_VARS);
|
return Object.keys(DERIVED_VARS);
|
||||||
},
|
},
|
||||||
|
|
@ -397,6 +528,7 @@ export default {
|
||||||
} else {
|
} else {
|
||||||
this.redrawFallback();
|
this.redrawFallback();
|
||||||
}
|
}
|
||||||
|
this.drawCandidateFontTests();
|
||||||
},
|
},
|
||||||
deep: true,
|
deep: true,
|
||||||
},
|
},
|
||||||
|
|
@ -407,6 +539,20 @@ export default {
|
||||||
this.redrawFallback();
|
this.redrawFallback();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
orientation() {
|
||||||
|
if (this.usbSupported) {
|
||||||
|
this.redraw();
|
||||||
|
} else {
|
||||||
|
this.redrawFallback();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
codeType() {
|
||||||
|
if (this.usbSupported) {
|
||||||
|
this.redraw();
|
||||||
|
} else {
|
||||||
|
this.redrawFallback();
|
||||||
|
}
|
||||||
|
},
|
||||||
// Covers connecting/disconnecting/switching printers - anything that changes the
|
// Covers connecting/disconnecting/switching printers - anything that changes the
|
||||||
// tape dimensions redraw() sizes the canvas from. flush: 'post' because the canvas
|
// tape dimensions redraw() sizes the canvas from. flush: 'post' because the canvas
|
||||||
// itself only exists once `tape` is truthy (see the v-if/v-else in the template), so
|
// itself only exists once `tape` is truthy (see the v-if/v-else in the template), so
|
||||||
|
|
@ -517,7 +663,7 @@ export default {
|
||||||
this.resizeObserver.observe(canvas.parentElement);
|
this.resizeObserver.observe(canvas.parentElement);
|
||||||
let textSizesPx;
|
let textSizesPx;
|
||||||
try {
|
try {
|
||||||
({textSizesPx} = drawLabel(canvas, this.tape, content));
|
({textSizesPx} = drawLabel(canvas, this.tape, content, this.orientation, this.codeType));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = e.message;
|
this.error = e.message;
|
||||||
return;
|
return;
|
||||||
|
|
@ -543,7 +689,7 @@ export default {
|
||||||
}
|
}
|
||||||
this.resizeObserver.observe(canvas.parentElement);
|
this.resizeObserver.observe(canvas.parentElement);
|
||||||
try {
|
try {
|
||||||
drawFallbackLabel(canvas, content);
|
drawFallbackLabel(canvas, content, this.orientation, this.codeType);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = e.message;
|
this.error = e.message;
|
||||||
return;
|
return;
|
||||||
|
|
@ -575,7 +721,17 @@ export default {
|
||||||
if (!(available > 0)) {
|
if (!(available > 0)) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
const zoom = Math.min(MAX_ZOOM, available / canvas.width, MAX_PREVIEW_HEIGHT_PX / canvas.height);
|
const rawZoom = Math.min(MAX_ZOOM, available / canvas.width, MAX_PREVIEW_HEIGHT_PX / canvas.height);
|
||||||
|
// When magnifying, round DOWN to a whole number: image-rendering:pixelated below
|
||||||
|
// only actually looks crisp when every source pixel maps to the *same* number of
|
||||||
|
// screen pixels - at a fractional zoom (the overwhelmingly common case, since rawZoom
|
||||||
|
// is just whatever ratio the tape/card happen to produce) some source pixels get
|
||||||
|
// rounded up to one extra screen pixel and others don't, unevenly warping fine,
|
||||||
|
// already-pixel-perfect detail like a crisp QR module or a tiny bitmap font glyph.
|
||||||
|
// Flooring (never rounding/ceiling) keeps the same "never bigger than available
|
||||||
|
// space" guarantee rawZoom already had. Shrinking (zoom < 1) has no equivalent "whole
|
||||||
|
// factor" to snap to - downsampling always blends source pixels - so it's left as-is.
|
||||||
|
const zoom = rawZoom >= 1 ? Math.max(1, Math.floor(rawZoom)) : rawZoom;
|
||||||
canvas.style.width = `${canvas.width * zoom}px`;
|
canvas.style.width = `${canvas.width * zoom}px`;
|
||||||
canvas.style.height = `${canvas.height * zoom}px`;
|
canvas.style.height = `${canvas.height * zoom}px`;
|
||||||
canvas.style.imageRendering = zoom >= 1 ? "pixelated" : "auto";
|
canvas.style.imageRendering = zoom >= 1 ? "pixelated" : "auto";
|
||||||
|
|
@ -607,17 +763,75 @@ export default {
|
||||||
onUsbChange() {
|
onUsbChange() {
|
||||||
this.guard(() => this.refreshDevices());
|
this.guard(() => this.refreshDevices());
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// A plain :ref="key" inside v-for would still get Vue's refInFor array-collecting
|
||||||
|
// behavior (see LabelLayoutPreview.vue's setTemplateCanvasRef for the same pattern), so
|
||||||
|
// this keys the canvases by the caller's own composite key string explicitly instead.
|
||||||
|
setCandidateCanvasRef(key, el) {
|
||||||
|
if (el) {
|
||||||
|
this.candidateCanvases[key] = el;
|
||||||
|
} else {
|
||||||
|
delete this.candidateCanvases[key];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Draws the live "Text" field's value into a single canvas at the exact size/family
|
||||||
|
// given - no layout math, no snapping, just ctx.font as requested, so what's judged here
|
||||||
|
// is the font itself rather than anything label.js's real pipeline does to it. Canvas size
|
||||||
|
// is measured from the text itself, and ink-centered vertically (see label.js's
|
||||||
|
// drawTextLeaf for the same idea) so a font with unreliable declared metrics still lands
|
||||||
|
// fully inside the canvas instead of clipped off the top/bottom.
|
||||||
|
drawCandidateCell(canvas, family, fontPx) {
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = this.varValues.text;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.font = `${fontPx}px "${family}"`;
|
||||||
|
const {width, actualBoundingBoxAscent: up, actualBoundingBoxDescent: down} = ctx.measureText(text);
|
||||||
|
canvas.width = Math.ceil(width) + 4;
|
||||||
|
canvas.height = Math.ceil(up + down) + 8;
|
||||||
|
// Re-set font/alignment - changing canvas.width/height above resets the 2D context.
|
||||||
|
ctx.font = `${fontPx}px "${family}"`;
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.textBaseline = "alphabetic";
|
||||||
|
ctx.fillStyle = "#fff";
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = "#000";
|
||||||
|
ctx.fillText(text, 2, (canvas.height + up - down) / 2);
|
||||||
|
canvas.style.width = `${canvas.width * this.candidateZoom}px`;
|
||||||
|
canvas.style.height = `${canvas.height * this.candidateZoom}px`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Draws every (candidateFonts x candidateSizes) cell - see the card in the template.
|
||||||
|
async drawCandidateFontTests() {
|
||||||
|
await Promise.all(this.candidateFonts
|
||||||
|
.map(({family}) => document.fonts.load(`20px "${family}"`).catch(() => {})));
|
||||||
|
for (const {family} of this.candidateFonts) {
|
||||||
|
for (const size of this.candidateSizes) {
|
||||||
|
const canvas = this.candidateCanvases[`${family}-${size}`];
|
||||||
|
this.drawCandidateCell(canvas, family, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.blob = null;
|
this.blob = null;
|
||||||
this.resizeObserver = null;
|
this.resizeObserver = null;
|
||||||
this.resizeTimer = null;
|
this.resizeTimer = null;
|
||||||
|
this.candidateCanvases = {};
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
this.drawCandidateFontTests();
|
||||||
this.resizeObserver = new ResizeObserver(this.handleContainerResize);
|
this.resizeObserver = new ResizeObserver(this.handleContainerResize);
|
||||||
|
// Kicked off here rather than awaited immediately, so it loads concurrently with
|
||||||
|
// MultiPrinterBlob below instead of serializing two independent wasm fetches - every
|
||||||
|
// redraw()/redrawFallback() call below still waits on it first, since a "qrcode" leaf
|
||||||
|
// throws (see label.js's encodeQr) until it resolves.
|
||||||
|
const qrReady = preloadQrEncoder();
|
||||||
if (!("usb" in navigator)) {
|
if (!("usb" in navigator)) {
|
||||||
this.usbSupported = false;
|
this.usbSupported = false;
|
||||||
await nextTick();
|
await Promise.all([nextTick(), qrReady]);
|
||||||
this.redrawFallback();
|
this.redrawFallback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -630,6 +844,7 @@ export default {
|
||||||
this.blobReady = true;
|
this.blobReady = true;
|
||||||
navigator.usb.addEventListener("connect", this.onUsbChange);
|
navigator.usb.addEventListener("connect", this.onUsbChange);
|
||||||
navigator.usb.addEventListener("disconnect", this.onUsbChange);
|
navigator.usb.addEventListener("disconnect", this.onUsbChange);
|
||||||
|
await qrReady;
|
||||||
await this.guard(() => this.refreshDevices());
|
await this.guard(() => this.refreshDevices());
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
|
|
@ -774,6 +989,13 @@ export default {
|
||||||
width: 5.5rem;
|
width: 5.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pixel-test-canvas {
|
||||||
|
display: block;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
.code-block {
|
.code-block {
|
||||||
background: rgba(127, 127, 127, .08);
|
background: rgba(127, 127, 127, .08);
|
||||||
border-radius: .35rem;
|
border-radius: .35rem;
|
||||||
|
|
|
||||||
435
frontend/src/views/Scan.vue
Normal file
435
frontend/src/views/Scan.vue
Normal file
|
|
@ -0,0 +1,435 @@
|
||||||
|
<template>
|
||||||
|
<BaseLayout>
|
||||||
|
<main class="content">
|
||||||
|
<div class="container-fluid p-0">
|
||||||
|
<h1 class="h3 mb-3">Scan a code</h1>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
|
||||||
|
|
||||||
|
<div v-if="!insecureContext" class="alert alert-warning">
|
||||||
|
This page isn't in a secure context ({{ insecureOrigin }}), so the camera API is
|
||||||
|
unavailable. From another device, use https:// (or http://localhost) instead.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-6 mb-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Scan from image</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<input ref="fileInput" type="file" accept="image/*" class="form-control mb-3"
|
||||||
|
@change="onFileInputChange">
|
||||||
|
<div class="dropzone" :class="{'dropzone-hover': dropHover}"
|
||||||
|
@dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||||
|
Drag & drop an image here, paste one, or use the file picker above.
|
||||||
|
</div>
|
||||||
|
<div class="scan-canvas-wrap" v-show="hasFileImage">
|
||||||
|
<canvas ref="fileCanvas" class="scan-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
<ul class="scan-results">
|
||||||
|
<li v-if="hasFileImage && !fileResults.length" class="scan-results-empty">
|
||||||
|
No codes found.
|
||||||
|
</li>
|
||||||
|
<li v-for="(r, i) in fileResults" :key="i">
|
||||||
|
<span class="scan-result-type">{{ r.type }}</span>
|
||||||
|
<span v-if="r.metaText" class="text-muted"> {{ r.metaText }}</span>
|
||||||
|
<div class="text-break">{{ r.text }}</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6 mb-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Scan from camera</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p v-if="!cameraSupported" class="text-muted">
|
||||||
|
This browser has no camera API available.
|
||||||
|
</p>
|
||||||
|
<template v-else>
|
||||||
|
<div class="row g-2 align-items-end mb-3">
|
||||||
|
<div class="col-auto flex-grow-1">
|
||||||
|
<label class="form-label">Camera</label>
|
||||||
|
<select class="form-control" v-model="selectedCameraId"
|
||||||
|
:disabled="cameraRunning">
|
||||||
|
<option value="">Auto (prefer rear camera)</option>
|
||||||
|
<option v-for="(c, i) in cameras" :key="c.deviceId"
|
||||||
|
:value="c.deviceId">
|
||||||
|
{{ c.label || ('Camera ' + (i + 1)) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button v-if="!cameraRunning" class="btn btn-primary"
|
||||||
|
:disabled="!insecureContext" @click="startCamera">
|
||||||
|
<b-icon-camera class="me-1"></b-icon-camera>
|
||||||
|
Start camera
|
||||||
|
</button>
|
||||||
|
<button v-else class="btn btn-outline-secondary" @click="stopCamera">
|
||||||
|
<b-icon-stop-fill class="me-1"></b-icon-stop-fill>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="cameraRes" class="text-muted small">{{ cameraRes }}</p>
|
||||||
|
<div class="video-wrap">
|
||||||
|
<video ref="video" autoplay muted playsinline></video>
|
||||||
|
<canvas ref="overlay"></canvas>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ul class="scan-results">
|
||||||
|
<li v-for="(c, i) in cameraLog" :key="i">
|
||||||
|
<span class="scan-result-type">{{ c.type }}</span>
|
||||||
|
<span v-if="c.metaText" class="text-muted"> {{ c.metaText }}</span>
|
||||||
|
<span class="text-muted"> @ {{ c.time }}</span>
|
||||||
|
<div class="text-break">{{ c.text }}</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</BaseLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
|
import {loadAnyDCode} from "../../vendor/anyd-qr.js";
|
||||||
|
|
||||||
|
// A decode result's metadata (see anyd-qr.js's SymbolMetadata) as one short, human-readable
|
||||||
|
// string, e.g. "(v7, ec=M, mask=3)" - shared by both the "from image" and "from camera" result
|
||||||
|
// lists below rather than each formatting it its own way.
|
||||||
|
function metaSummary(metadata) {
|
||||||
|
const parts = [];
|
||||||
|
if (metadata.version != null) {
|
||||||
|
parts.push(`v${metadata.version}`);
|
||||||
|
}
|
||||||
|
if (metadata.size) {
|
||||||
|
parts.push(metadata.size);
|
||||||
|
}
|
||||||
|
if (metadata.ecc) {
|
||||||
|
parts.push(`ec=${metadata.ecc}`);
|
||||||
|
}
|
||||||
|
if (metadata.mask != null) {
|
||||||
|
parts.push(`mask=${metadata.mask}`);
|
||||||
|
}
|
||||||
|
return parts.length ? `(${parts.join(", ")})` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// How long a camera-detected code's bounding box stays drawn on the overlay before fading, so a
|
||||||
|
// one-off decode doesn't leave a stale box on screen once the code's moved out of frame.
|
||||||
|
const OVERLAY_CLEAR_MS = 2000;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "Scan",
|
||||||
|
components: {
|
||||||
|
BaseLayout,
|
||||||
|
...BIcons
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
error: null,
|
||||||
|
insecureContext: window.isSecureContext,
|
||||||
|
insecureOrigin: `${window.location.protocol}//${window.location.hostname}`,
|
||||||
|
|
||||||
|
dropHover: false,
|
||||||
|
hasFileImage: false,
|
||||||
|
fileResults: [],
|
||||||
|
|
||||||
|
cameraSupported: Boolean(navigator.mediaDevices?.getUserMedia),
|
||||||
|
cameras: [],
|
||||||
|
selectedCameraId: "",
|
||||||
|
cameraRunning: false,
|
||||||
|
cameraRes: "",
|
||||||
|
cameraLog: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// Draws `file`/a pasted or dropped Blob onto fileCanvas and decodes whatever's in it -
|
||||||
|
// shared by the file input, drag&drop and paste handlers below rather than each
|
||||||
|
// duplicating the createImageBitmap/getImageData/decodeImage sequence.
|
||||||
|
async decodeBlob(file) {
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
const canvas = this.$refs.fileCanvas;
|
||||||
|
canvas.width = bitmap.width;
|
||||||
|
canvas.height = bitmap.height;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.drawImage(bitmap, 0, 0);
|
||||||
|
const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
|
||||||
|
const anyd = await this.anydPromise;
|
||||||
|
const results = anyd.decodeImage(imageData);
|
||||||
|
this.drawBoxes(ctx, results);
|
||||||
|
this.hasFileImage = true;
|
||||||
|
this.fileResults = results.map(r => ({...r, metaText: metaSummary(r.metadata)}));
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message ?? String(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
drawBoxes(ctx, results) {
|
||||||
|
ctx.lineWidth = Math.max(2, ctx.canvas.width / 300);
|
||||||
|
ctx.strokeStyle = "#3ce77c";
|
||||||
|
ctx.fillStyle = "#3ce77c";
|
||||||
|
ctx.font = `${Math.max(14, ctx.canvas.width / 60)}px sans-serif`;
|
||||||
|
for (const r of results) {
|
||||||
|
if (!r.box) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const {x0, y0, x1, y1} = r.box;
|
||||||
|
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);
|
||||||
|
ctx.fillText(r.type, x0, Math.max(14, y0 - 4));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onFileInputChange() {
|
||||||
|
const file = this.$refs.fileInput.files[0];
|
||||||
|
if (file) {
|
||||||
|
this.decodeBlob(file);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.dropHover = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
onDragLeave() {
|
||||||
|
this.dropHover = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
onDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.dropHover = false;
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) {
|
||||||
|
this.decodeBlob(file);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onPaste(e) {
|
||||||
|
const item = [...(e.clipboardData?.items ?? [])].find(i => i.type.startsWith("image/"));
|
||||||
|
if (item) {
|
||||||
|
this.decodeBlob(item.getAsFile());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Device labels are only populated once camera permission has actually been granted, so
|
||||||
|
// this is called again right after getUserMedia resolves in startCamera, not just once
|
||||||
|
// up front.
|
||||||
|
async populateCameraList() {
|
||||||
|
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
this.cameras = devices.filter(d => d.kind === "videoinput");
|
||||||
|
},
|
||||||
|
|
||||||
|
syncOverlaySize() {
|
||||||
|
const video = this.$refs.video;
|
||||||
|
const overlay = this.$refs.overlay;
|
||||||
|
if (!video || !overlay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
overlay.width = video.clientWidth;
|
||||||
|
overlay.height = video.clientHeight;
|
||||||
|
},
|
||||||
|
|
||||||
|
logDecode(code) {
|
||||||
|
this.cameraLog.unshift({
|
||||||
|
type: code.type,
|
||||||
|
text: code.text ?? "",
|
||||||
|
metaText: metaSummary(code.metadata),
|
||||||
|
time: new Date().toLocaleTimeString(),
|
||||||
|
});
|
||||||
|
// Caps the log rather than letting it grow forever - old entries are trimmed off the
|
||||||
|
// end, same as CameraScanner's own dedupe window makes them stale for re-matching.
|
||||||
|
this.cameraLog.length = Math.min(this.cameraLog.length, 20);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Draws every detected code's box (in the video's own native pixel space, scaled to
|
||||||
|
// however large the overlay is actually displayed) and fades them out after
|
||||||
|
// OVERLAY_CLEAR_MS - a one-off decode shouldn't leave a stale box on screen once the code
|
||||||
|
// has moved out of frame.
|
||||||
|
drawOverlay(codes) {
|
||||||
|
const overlay = this.$refs.overlay;
|
||||||
|
const video = this.$refs.video;
|
||||||
|
if (!overlay || !video || !video.videoWidth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = overlay.getContext("2d");
|
||||||
|
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
||||||
|
const scaleX = overlay.width / video.videoWidth;
|
||||||
|
const scaleY = overlay.height / video.videoHeight;
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.strokeStyle = "#3ce77c";
|
||||||
|
ctx.fillStyle = "#3ce77c";
|
||||||
|
ctx.font = "14px sans-serif";
|
||||||
|
for (const code of codes) {
|
||||||
|
if (!code.box) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const {x0, y0, x1, y1} = code.box;
|
||||||
|
ctx.strokeRect(x0 * scaleX, y0 * scaleY, (x1 - x0) * scaleX, (y1 - y0) * scaleY);
|
||||||
|
ctx.fillText(`${code.type}: ${code.text ?? ""}`, x0 * scaleX, Math.max(14, y0 * scaleY - 4));
|
||||||
|
}
|
||||||
|
clearTimeout(this.clearOverlayTimer);
|
||||||
|
this.clearOverlayTimer = setTimeout(
|
||||||
|
() => ctx.clearRect(0, 0, overlay.width, overlay.height), OVERLAY_CLEAR_MS);
|
||||||
|
},
|
||||||
|
|
||||||
|
async startCamera() {
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
const constraints = {
|
||||||
|
video: this.selectedCameraId
|
||||||
|
? {deviceId: {exact: this.selectedCameraId}}
|
||||||
|
: {facingMode: {ideal: "environment"}},
|
||||||
|
audio: false,
|
||||||
|
};
|
||||||
|
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
const video = this.$refs.video;
|
||||||
|
video.srcObject = this.stream;
|
||||||
|
await video.play();
|
||||||
|
this.syncOverlaySize();
|
||||||
|
this.cameraRes = `Native resolution: ${video.videoWidth} x ${video.videoHeight}`;
|
||||||
|
await this.populateCameraList();
|
||||||
|
|
||||||
|
const anyd = await this.anydPromise;
|
||||||
|
this.scanner = anyd.createCameraScanner(video, {
|
||||||
|
fps: 12,
|
||||||
|
downscale: 2,
|
||||||
|
onDecode: (codes) => {
|
||||||
|
codes.forEach(this.logDecode);
|
||||||
|
this.drawOverlay(codes);
|
||||||
|
},
|
||||||
|
onError: (err) => console.error("[camera scan]", err),
|
||||||
|
});
|
||||||
|
this.scanner.start();
|
||||||
|
this.cameraRunning = true;
|
||||||
|
} catch (e) {
|
||||||
|
this.error = `Camera error: ${e.message ?? e}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
stopCamera() {
|
||||||
|
this.scanner?.stop();
|
||||||
|
this.scanner = null;
|
||||||
|
this.stream?.getTracks().forEach(t => t.stop());
|
||||||
|
this.stream = null;
|
||||||
|
if (this.$refs.video) {
|
||||||
|
this.$refs.video.srcObject = null;
|
||||||
|
}
|
||||||
|
this.cameraRunning = false;
|
||||||
|
const overlay = this.$refs.overlay;
|
||||||
|
if (overlay) {
|
||||||
|
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
// Kept as a promise (rather than awaited here) so every caller - decodeBlob, startCamera -
|
||||||
|
// shares the same in-flight load instead of each triggering its own; loadAnyDCode's own
|
||||||
|
// memoization (see anyd-qr.js) means a second call anywhere else in the app is free too.
|
||||||
|
this.anydPromise = loadAnyDCode();
|
||||||
|
this.stream = null;
|
||||||
|
this.scanner = null;
|
||||||
|
this.clearOverlayTimer = null;
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
window.addEventListener("paste", this.onPaste);
|
||||||
|
window.addEventListener("resize", this.syncOverlaySize);
|
||||||
|
this.populateCameraList();
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
this.stopCamera();
|
||||||
|
window.removeEventListener("paste", this.onPaste);
|
||||||
|
window.removeEventListener("resize", this.syncOverlaySize);
|
||||||
|
clearTimeout(this.clearOverlayTimer);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dropzone {
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
border: 2px dashed var(--bs-gray-400);
|
||||||
|
border-radius: .35rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--bs-gray-600);
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone-hover {
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-canvas-wrap {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-canvas {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: .35rem;
|
||||||
|
border: 1px solid var(--bs-gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
background: #000;
|
||||||
|
border-radius: .35rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-wrap video {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-wrap canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .4rem;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results li {
|
||||||
|
border: 1px solid var(--bs-gray-300);
|
||||||
|
border-radius: .35rem;
|
||||||
|
padding: .4rem .6rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-result-type {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bs-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-results-empty {
|
||||||
|
color: var(--bs-gray-600);
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
143
frontend/src/views/ShortId.vue
Normal file
143
frontend/src/views/ShortId.vue
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
<template>
|
||||||
|
<BaseLayout hide-search>
|
||||||
|
<main class="content">
|
||||||
|
<div class="container-fluid p-0">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Decoded</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul>
|
||||||
|
<li v-for="(value, index) in decoded" :key="index">{{ value }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Deserialized</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>kind: {{ deserialized.kind }}</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(value, name) in fields" :key="name">{{ name }}: {{ value }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Examples</div>
|
||||||
|
<table class="table table-striped mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Fields</th>
|
||||||
|
<th>Serialized</th>
|
||||||
|
<th>Bits</th>
|
||||||
|
<th>Token</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="example in examples" :key="example.token">
|
||||||
|
<td>{{ example.kind }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-for="(value, name) in example.fields" :key="name" class="me-2">
|
||||||
|
{{ name }}: {{ value }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>[{{ example.ints.join(', ') }}]</td>
|
||||||
|
<td><code>{{ example.bits }}</code></td>
|
||||||
|
<td>
|
||||||
|
<router-link :to="'/' + example.token">{{ example.token }}</router-link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</BaseLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import BaseLayout from '@/components/BaseLayout.vue';
|
||||||
|
import {decodeShortId, deserializeShortId, encodeShortId, serializeShortId} from '@/short-id';
|
||||||
|
|
||||||
|
const EXAMPLES = [
|
||||||
|
{kind: 'item', owner_identity_id: 7, item_local_id: 42},
|
||||||
|
{kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000},
|
||||||
|
{kind: 'category', category_id: 5},
|
||||||
|
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
|
||||||
|
{kind: 'group', group_id: 11},
|
||||||
|
{kind: 'file', file_id: 123},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Debug-only display helper, kept out of short-id.js's production library: mirrors its bit-packing
|
||||||
|
// rules (2-bit kind tag with an all-ones escape, 4-bit continuation chunks, see
|
||||||
|
// docs/handles-and-shortids.md) just to show them, recomputed straight from an already-serialized
|
||||||
|
// `ints` list rather than re-parsing a token.
|
||||||
|
const KIND_TAG_BITS = 2;
|
||||||
|
const CHUNK_BITS = 4;
|
||||||
|
const DIRECT_KIND_COUNT = 2 ** KIND_TAG_BITS - 1;
|
||||||
|
|
||||||
|
function chunkedIntBits(value, chunkBits) {
|
||||||
|
value = BigInt(value);
|
||||||
|
const mask = (1n << BigInt(chunkBits)) - 1n;
|
||||||
|
const chunks = [];
|
||||||
|
let remaining = value;
|
||||||
|
do {
|
||||||
|
chunks.unshift(remaining & mask);
|
||||||
|
remaining >>= BigInt(chunkBits);
|
||||||
|
} while (remaining > 0n);
|
||||||
|
return chunks.map((chunk, i) => {
|
||||||
|
const continuation = i === chunks.length - 1 ? '0' : '1';
|
||||||
|
return continuation + chunk.toString(2).padStart(chunkBits, '0');
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortIdBitSegments(ints) {
|
||||||
|
const [kindIndex, ...fieldValues] = ints;
|
||||||
|
const segments = [];
|
||||||
|
if (kindIndex < DIRECT_KIND_COUNT) {
|
||||||
|
segments.push(kindIndex.toString(2).padStart(KIND_TAG_BITS, '0'));
|
||||||
|
} else {
|
||||||
|
segments.push('1'.repeat(KIND_TAG_BITS));
|
||||||
|
segments.push(chunkedIntBits(kindIndex - DIRECT_KIND_COUNT, CHUNK_BITS));
|
||||||
|
}
|
||||||
|
fieldValues.forEach(value => segments.push(chunkedIntBits(value, CHUNK_BITS)));
|
||||||
|
|
||||||
|
const totalBits = segments.join('').length;
|
||||||
|
const paddingBits = (6 - (totalBits % 6)) % 6;
|
||||||
|
if (paddingBits > 0) {
|
||||||
|
segments.push('0'.repeat(paddingBits));
|
||||||
|
}
|
||||||
|
return segments.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ShortId',
|
||||||
|
components: {
|
||||||
|
BaseLayout
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
short_id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
decoded() {
|
||||||
|
return decodeShortId(this.short_id);
|
||||||
|
},
|
||||||
|
deserialized() {
|
||||||
|
return deserializeShortId(this.decoded);
|
||||||
|
},
|
||||||
|
fields() {
|
||||||
|
const {kind, ...fields} = this.deserialized;
|
||||||
|
return fields;
|
||||||
|
},
|
||||||
|
examples() {
|
||||||
|
return EXAMPLES.map(named => {
|
||||||
|
const {kind, ...fields} = named;
|
||||||
|
const ints = serializeShortId(named);
|
||||||
|
const token = encodeShortId(ints);
|
||||||
|
return {kind, fields, ints, token, bits: shortIdBitSegments(ints)};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
1
frontend/vendor/anyd-qr.js
vendored
Normal file
1
frontend/vendor/anyd-qr.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue