Compare commits

...

7 commits

Author SHA1 Message Date
Jan Böhmer
46617f01a4 Added documentation about the barcode scanner
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build / merge (push) Blocked by required conditions
Docker Image Build (FrankenPHP) / build (linux/amd64, amd64, ubuntu-latest) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm/v7, armv7, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / build (linux/arm64, arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Image Build (FrankenPHP) / merge (push) Blocked by required conditions
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
2026-03-01 18:11:58 +01:00
Jan Böhmer
f097b79103 Navigate only the content frame when submitting the global barcode scan label 2026-03-01 16:56:47 +01:00
Jan Böhmer
a8f9f9832e Correctly dispatch the input event of non-printable char controller from the barcode scan controller 2026-03-01 16:51:06 +01:00
Jan Böhmer
f3dab36bbe Allow to scan labels anywhere on the page 2026-03-01 16:48:29 +01:00
Jan Böhmer
bebd603117 Allow to handle non-printable inputs like from an attached barcode scanner 2026-03-01 14:39:14 +01:00
Jan Böhmer
2660f4ee82 Render non-printable chars in the scan input field 2026-03-01 13:36:52 +01:00
Jan Böhmer
eb2bbdd633 Show label scan input with monospaced font 2026-03-01 13:00:08 +01:00
6 changed files with 312 additions and 5 deletions

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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);
});
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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();
}
}

View file

@ -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();
}

51
docs/usage/scanner.md Normal file
View file

@ -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 <SOH> (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.

View file

@ -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',
],
]);

View file

@ -2,7 +2,7 @@
<!DOCTYPE html>
<html lang="{{ app.request.locale | replace({"_": "-"}) }}"
{# For the UX translator, just use the language part (before the _. should be 2 chars), otherwise it finds no translations #}
{# For the UX translator, just use the language part (before the _. should be 2 chars), otherwise it finds no translations #}
data-symfony-ux-translator-locale="{{ app.request.locale|u.truncate(2) }}">
<head>
<meta charset="UTF-8">
@ -73,9 +73,17 @@
{{ encore_entry_script_tags('webauthn_tfa') }}
{% endblock %}
</head>
<body data-base-url="{{ path('homepage', {'_locale': app.request.locale}) }}"
<body data-base-url="{{ path('homepage', {'_locale': app.request.locale}) }}"
data-locale="{{ app.request.locale|default("en")|slice(0,2) }}"
data-keybindings-special-characters="{{ settings_instance('keybindings').enableSpecialCharacters ? 'true' : 'false' }}">
{# Listen for the special #}
{% if is_granted("@tools.label_scanner") %}
<form class="d-none" {{ stimulus_controller('helpers/scan_special_char') }} action="{{ path("scan_dialog") }}" data-turbo-frame="content">
<input name="input" type="hidden">
</form>
{% endif %}
{% block body %}
<header>
<turbo-frame id="navbar-frame" target="content" data-turbo-action="advance">
@ -121,13 +129,13 @@
<!-- Back to top button -->
<button id="back-to-top" class="btn btn-primary back-to-top btn-sm" role="button" title="{% trans %}back_to_top{% endtrans %}"
{{ stimulus_controller('common/back_to_top') }} {{ stimulus_action('common/back_to_top', 'backToTop') }}>
{{ stimulus_controller('common/back_to_top') }} {{ stimulus_action('common/back_to_top', 'backToTop') }}>
<i class="fas fa-angle-up fa-fw"></i>
</button>
{# Must be outside of the sidebar or it will be hidden too #}
<button class="btn btn-outline-secondary btn-sm p-0 d-md-block d-none" type="button" id="sidebar-toggle-button" title="{% trans %}sidebar.big.toggle{% endtrans %}"
{{ stimulus_controller('common/hide_sidebar') }} {{ stimulus_action('common/hide_sidebar', 'toggleSidebar') }} style="--fa-width: 10px;">
{{ stimulus_controller('common/hide_sidebar') }} {{ stimulus_action('common/hide_sidebar', 'toggleSidebar') }} style="--fa-width: 10px;">
<i class="fas fa-angle-left"></i>
</button>