diff --git a/assets/controllers/elements/nonprintable_char_input_controller.js b/assets/controllers/elements/nonprintable_char_input_controller.js new file mode 100644 index 00000000..bd172f1b --- /dev/null +++ b/assets/controllers/elements/nonprintable_char_input_controller.js @@ -0,0 +1,106 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {Controller} from "@hotwired/stimulus"; + +/** + * Purpose of this controller is to allow users to input non-printable characters like EOT, FS, etc. in a form field and submit them correctly with the form. + * The visible input field encodes non-printable characters via their Unicode Control picture representation, e.g. \n becomes ␊ and \t becomes ␉, so that they can be displayed in the input field without breaking the form submission. + * The actual value of the field, which is submitted with the form, is stored in a hidden input and contains the non-printable characters in their original form. + */ +export default class extends Controller { + + _hiddenInput; + + connect() { + this.element.addEventListener("input", this._update.bind(this)); + + // We use a hidden input to store the actual value of the field, which is submitted with the form. + // The visible input is just for user interaction and can contain non-printable characters, which are not allowed in the hidden input. + this._hiddenInput = document.createElement("input"); + this._hiddenInput.type = "hidden"; + this._hiddenInput.name = this.element.name; + this.element.removeAttribute("name"); + this.element.parentNode.insertBefore(this._hiddenInput, this.element.nextSibling); + + this.element.addEventListener("keypress", this._onKeyPress.bind(this)); + } + + /** + * Ensures that non-printable characters like EOT, FS, etc. gets added to the input value when the user types them + * @param event + * @private + */ + _onKeyPress(event) { + const ALLOWED_INPUT_CODES = [4, 28, 29, 30, 31]; //EOT, FS, GS, RS, US + + if (!ALLOWED_INPUT_CODES.includes(event.keyCode)) { + return; + } + + event.preventDefault(); + + const char = String.fromCharCode(event.keyCode); + this.element.value += char; + + this._update(); + + + } + + _update() { + //Chrome workaround: Remove a leading ∠ character (U+2220) that appears when the user types a non-printable character at the beginning of the input field. + if (this.element.value.startsWith("∠")) { + this.element.value = this.element.value.substring(1); + } + + // Remove non-printable characters from the input value and store them in the hidden input + const normalizedValue = this.decodeNonPrintableChars(this.element.value); + this._hiddenInput.value = normalizedValue; + + // Encode non-printable characters in the visible input to their Unicode Control picture representation + const encodedValue = this.encodeNonPrintableChars(normalizedValue); + if (encodedValue !== this.element.value) { + this.element.value = encodedValue; + } + } + + /** + * Encodes non-printable characters in the given string via their Unicode Control picture representation, e.g. \n becomes ␊ and \t becomes ␉. + * This allows us to display non-printable characters in the input field without breaking the form submission. + * @param str + */ + encodeNonPrintableChars(str) { + return str.replace(/[\x00-\x1F\x7F]/g, (char) => { + const code = char.charCodeAt(0); + return String.fromCharCode(0x2400 + code); + }); + } + + /** + * Decodes the Unicode Control picture representation of non-printable characters back to their original form, e.g. ␊ becomes \n and ␉ becomes \t. + * @param str + */ + decodeNonPrintableChars(str) { + return str.replace(/[\u2400-\u241F\u2421]/g, (char) => { + const code = char.charCodeAt(0) - 0x2400; + return String.fromCharCode(code); + }); + } +} diff --git a/assets/controllers/helpers/scan_special_char_controller.js b/assets/controllers/helpers/scan_special_char_controller.js new file mode 100644 index 00000000..154b2a94 --- /dev/null +++ b/assets/controllers/helpers/scan_special_char_controller.js @@ -0,0 +1,136 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Controller } from "@hotwired/stimulus" + +/** + * This controller listens for a special non-printable character (SOH / ASCII 1) to be entered anywhere on the page, + * which is then used as a trigger to submit the following characters as a barcode / scan input. + */ +export default class extends Controller { + connect() { + // Optional: Log to confirm global attachment + console.log("Scanner listener active") + + this.isCapturing = false + this.buffer = "" + + window.addEventListener("keypress", this.handleKeydown.bind(this)) + } + + initialize() { + this.isCapturing = false + this.buffer = "" + this.timeoutId = null + } + + handleKeydown(event) { + + // Ignore if the user is typing in a form field + const isInput = ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName) || + event.target.isContentEditable; + if (isInput) return + + // 1. Detect Start of Header (SOH / Ctrl+A) + if (event.key === "\x01" || event.keyCode === 1) { + this.startCapturing(event) + return + } + + // 2. Process characters if in capture mode + if (this.isCapturing) { + this.resetTimeout() // Push the expiration back with every keypress + + if (event.key === "Enter" || event.keyCode === 13) { + + this.finishCapturing(event) + } else if (event.key.length === 1) { + this.buffer += event.key + } + } + } + + startCapturing(event) { + this.isCapturing = true + this.buffer = "" + this.resetTimeout() + event.preventDefault() + console.debug("Scan character detected. Capture started...") + } + + finishCapturing(event) { + event.preventDefault() + const data = this.buffer; + this.stopCapturing() + this.processCapture(data) + } + + stopCapturing() { + this.isCapturing = false + this.buffer = "" + if (this.timeoutId) clearTimeout(this.timeoutId) + console.debug("Capture cleared/finished.") + } + + resetTimeout() { + if (this.timeoutId) clearTimeout(this.timeoutId) + + this.timeoutId = setTimeout(() => { + if (this.isCapturing) { + console.warn("Capture timed out. Resetting buffer.") + this.stopCapturing() + } + }, 500) + } + + processCapture(data) { + if (!data) return + + console.debug("Captured scan data: " + data) + + const scanInput = document.getElementById("scan_dialog_input"); + if (scanInput) { //When we are on the scan dialog page, submit the form there + this._submitScanForm(data); + } else { //Otherwise use our own form (e.g. on the part list page) + this.element.querySelector("input[name='input']").value = data; + this.element.requestSubmit(); + } + + + } + + _submitScanForm(data) { + const scanInput = document.getElementById("scan_dialog_input"); + if (!scanInput) { + console.error("Scan input field not found!") + return; + } + + scanInput.value = data; + scanInput.dispatchEvent(new Event('input', { bubbles: true })); + + const form = document.getElementById("scan_dialog_form"); + if (!form) { + console.error("Scan form not found!") + return; + } + + form.requestSubmit(); + } +} diff --git a/assets/controllers/pages/barcode_scan_controller.js b/assets/controllers/pages/barcode_scan_controller.js index ae51e951..bdc9c78c 100644 --- a/assets/controllers/pages/barcode_scan_controller.js +++ b/assets/controllers/pages/barcode_scan_controller.js @@ -114,7 +114,11 @@ export default class extends Controller { // Mark as handled immediately (prevents spam even if callback fires repeatedly) this._lastDecodedText = normalized; - document.getElementById('scan_dialog_input').value = decodedText; + const input = document.getElementById('scan_dialog_input'); + input.value = decodedText; + //Trigger nonprintable char input controller to update the hidden input value + input.dispatchEvent(new Event('input', { bubbles: true })); + //Submit form document.getElementById('scan_dialog_form').requestSubmit(); } diff --git a/docs/usage/scanner.md b/docs/usage/scanner.md new file mode 100644 index 00000000..47b3feff --- /dev/null +++ b/docs/usage/scanner.md @@ -0,0 +1,51 @@ +--- +title: Barcode Scanner +layout: default +parent: Usage +--- + +# Barcode scanner + +When the user has the correct permission there will be a barcode scanner button in the navbar. +On this page you can either input a barcode code by hand, use an external barcode scanner, or use your devices camera to +scan a barcode. + +In info mode (when the "Info" toggle is enabled) you can scan a barcode and Part-DB will parse it and show information +about it. + +Without info mode, the barcode will directly redirect you to the corresponding page. + +### Barcode matching + +When you scan a barcode, Part-DB will try to match it to an existing part, part lot or storage location first. +For Part-DB generated barcodes, it will use the internal ID of a part. Alternatively you can also scan a barcode that contains the part's IPN. + +You can set a GTIN/EAN code in the part properties and Part-DB will open the part page when you scan the corresponding GTIN/EAN barcode. + +On a part lot you can under "Advanced" set a user barcode, that will redirect you to the part lot page when scanned. This allows to reuse +arbitrary existing barcodes that already exist on the part lots (for example, from the manufacturer) and link them to the part lot in Part-DB. + +Part-DB can also parse various distributor barcodes (for example from Digikey and Mouser) and will try to redirect you to the corresponding +part page based on the distributor part number in the barcode. + +### Part creation from barcodes +For certain barcodes Part-DB can automatically create a new part, when it cannot find a matching part. +Part-DB will try to retrieve the part information from an information provider and redirects you to the part creation page +with the retrieved information pre-filled. + +## Using an external barcode scanner + +Part-DB supports the use of external barcode scanners that emulate keyboard input. To use a barcode scanner with Part-DB, +simply connect the scanner to your computer and scan a barcode while the cursor is in a text field in Part-DB. +The scanned barcode will be entered into the text field as if you had typed it on the keyboard. + +In scanner fields, it will also try to insert special non-printable characters the scanner send via Alt + key combinations. +This is required for EIGP114 datamatrix codes. + +### Automatically redirect on barcode scanning + +If you configure your barcode scanner to send a (Start of heading, 0x01) non-printable character at the beginning +of the scanned barcode, Part-DB will automatically scan the barcode that comes afterward (and is ended with an enter key) +and redirects you to the corresponding page. +This allows you to quickly scan a barcode from anywhere in Part-DB without the need to first open the scanner page. +If an input field is focused, the barcode will be entered into the field as usual and no redirection will happen. diff --git a/src/Form/LabelSystem/ScanDialogType.php b/src/Form/LabelSystem/ScanDialogType.php index cd1440c6..5f9ce65f 100644 --- a/src/Form/LabelSystem/ScanDialogType.php +++ b/src/Form/LabelSystem/ScanDialogType.php @@ -61,6 +61,8 @@ class ScanDialogType extends AbstractType 'attr' => [ 'autofocus' => true, 'id' => 'scan_dialog_input', + 'style' => 'font-family: var(--bs-font-monospace)', + 'data-controller' => 'elements--nonprintable-char-input', ], ]); diff --git a/templates/base.html.twig b/templates/base.html.twig index 62f0ce53..afc7a8bf 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -2,7 +2,7 @@ @@ -73,9 +73,17 @@ {{ encore_entry_script_tags('webauthn_tfa') }} {% endblock %} - + +{# Listen for the special #} +{% if is_granted("@tools.label_scanner") %} +
+ +
+{% endif %} + {% block body %}
@@ -121,13 +129,13 @@ {# Must be outside of the sidebar or it will be hidden too #}