diff --git a/assets/controllers/elements/nonprintable_char_input_controller.js b/assets/controllers/elements/nonprintable_char_input_controller.js
index f93833ee..bd172f1b 100644
--- a/assets/controllers/elements/nonprintable_char_input_controller.js
+++ b/assets/controllers/elements/nonprintable_char_input_controller.js
@@ -19,12 +19,17 @@
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("change", this._update.bind(this));
+ 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.
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/templates/base.html.twig b/templates/base.html.twig
index 62f0ce53..4b49cb54 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 #}