d40c74870e
toggleSign() usciva subito a display vuoto: il segno del calibro, che arriva prima delle cifre, veniva scartato. Ora '-'/'+' impostano il segno in modo esplicito (non toggle) quando arrivano da tastiera/HID; toggleSign() (bottone manuale +/-) funziona anche a vuoto. _trackBurst riconosce l'inizio di una raffica solo dal secondo carattere: prima ripuliva il display in ritardo, concatenando cifre digitate a mano. Ora la raffica ripulisce il display fino al proprio inizio quando la pausa prima di essa è > 300ms. Parte intera oltre 6 cifre: rifiutata con evento numpad-error invece di essere troncata in silenzio. Test node in tests/js/test_numpad.js, lanciato da pytest (tests/test_numpad_js.py, skip se node manca). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
317 lines
10 KiB
JavaScript
317 lines
10 KiB
JavaScript
/**
|
|
* Numpad Component - Alpine.js component for touch-friendly numeric input
|
|
* Used for measurement data entry in task_execute.html
|
|
*/
|
|
|
|
function numpad(options) {
|
|
var opts = options || {};
|
|
|
|
return {
|
|
// State
|
|
value: '', // String representation of the current value
|
|
negative: false, // Whether the value is negative
|
|
hasDecimal: false, // Whether a decimal point has been entered
|
|
unit: 'mm', // Unit of measurement (can be set externally)
|
|
maxIntDigits: 6, // Maximum integer digits
|
|
maxDecDigits: 6, // Maximum decimal digits
|
|
|
|
/* Whether a value may be typed at all, as the recipe declares it. When false
|
|
the keypad shows no digits and a value that looks typed is refused here as
|
|
well as by the server: the caliper is the instrument. Defaults to allowed,
|
|
so a caller that says nothing gets the behaviour that came before. */
|
|
allowManual: opts.allowManual !== false,
|
|
|
|
// HID burst detection (USB caliper vs manual typing)
|
|
_lastKeyTime: 0, // Timestamp of last keystroke
|
|
_burstCount: 0, // Consecutive fast keystrokes
|
|
_enterWasFast: false, // Enter arrived in the same burst as the digits
|
|
_rejected: false, // Current reading refused (e.g. overflow); ignore the rest of it
|
|
_runStart: null, // {value, gap} snapshot taken at the first key of the current run
|
|
|
|
/**
|
|
* Get the display value with sign
|
|
*/
|
|
get displayValue() {
|
|
if (!this.value) return '';
|
|
return (this.negative ? '-' : '') + this.value;
|
|
},
|
|
|
|
/**
|
|
* Get the numeric value as a number
|
|
*/
|
|
get numericValue() {
|
|
if (!this.value) return null;
|
|
const v = parseFloat(this.value);
|
|
return this.negative ? -v : v;
|
|
},
|
|
|
|
/**
|
|
* Check if a valid value has been entered
|
|
*/
|
|
get hasValue() {
|
|
return this.value.length > 0 && this.value !== '.';
|
|
},
|
|
|
|
/**
|
|
* Add a digit to the current value
|
|
* @param {string} d - The digit to add (0-9)
|
|
*/
|
|
addDigit(d) {
|
|
if (this._rejected) return;
|
|
|
|
// Validate: don't exceed max digits
|
|
const parts = this.value.split('.');
|
|
|
|
if (this.hasDecimal) {
|
|
// Check decimal part length
|
|
if (parts[1] && parts[1].length >= this.maxDecDigits) return;
|
|
} else {
|
|
// Check integer part length
|
|
if (parts[0] && parts[0].length >= this.maxIntDigits) {
|
|
// A caliper reading whose integer part does not fit must not be
|
|
// quietly cut down to something that does: refuse the whole
|
|
// reading instead of saving a truncated number. Manual typing
|
|
// (outside a burst) keeps the old, harmless behaviour of simply
|
|
// not accepting more digits.
|
|
if (this._burstCount >= 2) this._rejectReading('integer_overflow');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Prevent leading zeros (except "0.")
|
|
if (this.value === '0' && d !== '.') {
|
|
this.value = d;
|
|
return;
|
|
}
|
|
|
|
this.value += d;
|
|
},
|
|
|
|
/**
|
|
* Add a decimal point to the current value
|
|
*/
|
|
addDecimal() {
|
|
if (this._rejected) return;
|
|
|
|
// Don't add decimal if one already exists
|
|
if (this.hasDecimal) return;
|
|
|
|
// If value is empty, start with "0."
|
|
if (!this.value) this.value = '0';
|
|
|
|
this.value += '.';
|
|
this.hasDecimal = true;
|
|
},
|
|
|
|
/**
|
|
* Toggle the sign of the current value. Works on an empty display too -
|
|
* the manual +/- key is pressed before the first digit as often as after
|
|
* one, and used to be silently dropped in that case.
|
|
*/
|
|
toggleSign() {
|
|
this.negative = !this.negative;
|
|
},
|
|
|
|
/**
|
|
* Remove the last character from the current value
|
|
*/
|
|
backspace() {
|
|
if (!this.value) return;
|
|
|
|
const removed = this.value.charAt(this.value.length - 1);
|
|
|
|
// If removing decimal point, update flag
|
|
if (removed === '.') this.hasDecimal = false;
|
|
|
|
this.value = this.value.slice(0, -1);
|
|
},
|
|
|
|
/**
|
|
* Clear all entered data
|
|
*/
|
|
clearAll() {
|
|
this.value = '';
|
|
this.negative = false;
|
|
this.hasDecimal = false;
|
|
this._enterWasFast = false;
|
|
this._rejected = false;
|
|
this._runStart = null;
|
|
},
|
|
|
|
/**
|
|
* How the value in the display got there.
|
|
*
|
|
* A wedge caliper sends its digits and the Enter that follows them as one
|
|
* burst; a person is slower on both. The Enter is the stronger of the two
|
|
* signals: a short reading like "9.5" is only three keystrokes, too few to
|
|
* judge by count alone, and used to be filed as typed by hand.
|
|
*/
|
|
_classifyInput() {
|
|
if (this._enterWasFast) return 'usb_caliper';
|
|
return this._burstCount >= 3 ? 'usb_caliper' : 'manual';
|
|
},
|
|
|
|
/**
|
|
* Confirm the current value and dispatch event
|
|
*/
|
|
confirm() {
|
|
this._rejected = false;
|
|
if (!this.hasValue) return;
|
|
|
|
const val = this.numericValue;
|
|
const inputMethod = this._classifyInput();
|
|
|
|
// The recipe forbids typing: say so and keep the value on screen rather than
|
|
// clearing it, so the operator sees what was refused. The server refuses the
|
|
// same request anyway - this is only the earlier, kinder of the two answers.
|
|
if (inputMethod === 'manual' && !this.allowManual) {
|
|
this.$dispatch('numpad-rejected', { reason: 'manual_not_allowed', value: val });
|
|
this._enterWasFast = false;
|
|
this._burstCount = 0;
|
|
this._lastKeyTime = 0;
|
|
return;
|
|
}
|
|
|
|
// Dispatch custom event for parent component to handle
|
|
this.$dispatch('numpad-confirm', { value: val, inputMethod: inputMethod });
|
|
|
|
// Reset after confirmation
|
|
this.clearAll();
|
|
this._burstCount = 0;
|
|
this._lastKeyTime = 0;
|
|
},
|
|
|
|
/**
|
|
* Set a value programmatically (e.g., from USB caliper)
|
|
* @param {number} val - The numeric value to set
|
|
*/
|
|
setValue(val) {
|
|
this.clearAll();
|
|
|
|
// Handle negative values
|
|
if (val < 0) {
|
|
this.negative = true;
|
|
val = Math.abs(val);
|
|
}
|
|
|
|
this.value = val.toString();
|
|
|
|
// Update decimal flag if value contains decimal point
|
|
if (this.value.includes('.')) this.hasDecimal = true;
|
|
},
|
|
|
|
/**
|
|
* Set the unit of measurement
|
|
* @param {string} newUnit - The unit to set (e.g., 'mm', 'cm', 'in')
|
|
*/
|
|
setUnit(newUnit) {
|
|
this.unit = newUnit;
|
|
},
|
|
|
|
/**
|
|
* Handle keyboard input for physical keyboard support
|
|
* @param {KeyboardEvent} e - The keyboard event
|
|
*/
|
|
handleKeydown(e) {
|
|
// Ignore keystrokes aimed at an editable field (e.g. the supervisor
|
|
// login modal). The numpad has no text inputs of its own, so this
|
|
// window-level capture is only meant for the USB caliper / keyboard
|
|
// wedge when no field is focused. Without this guard the numpad would
|
|
// swallow digits and Backspace inside those inputs.
|
|
const t = e.target;
|
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
|
|
t.tagName === 'SELECT' || t.isContentEditable)) {
|
|
return;
|
|
}
|
|
|
|
// Number keys
|
|
if (e.key >= '0' && e.key <= '9') {
|
|
e.preventDefault();
|
|
this._trackBurst();
|
|
this.addDigit(e.key);
|
|
}
|
|
// Decimal point (both . and ,)
|
|
else if (e.key === '.' || e.key === ',') {
|
|
e.preventDefault();
|
|
this._trackBurst();
|
|
this.addDecimal();
|
|
}
|
|
// Backspace
|
|
else if (e.key === 'Backspace') {
|
|
e.preventDefault();
|
|
this.backspace();
|
|
}
|
|
// Escape - clear all
|
|
else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
this.clearAll();
|
|
}
|
|
// Enter - confirm
|
|
else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
// Measured before confirming: an Enter that lands within the burst is the
|
|
// caliper closing its own transmission, not a person reaching for a key.
|
|
const gap = this._lastKeyTime > 0
|
|
? performance.now() - this._lastKeyTime
|
|
: Infinity;
|
|
this._enterWasFast = gap < 80;
|
|
this.confirm();
|
|
}
|
|
// Minus / plus - the caliper's own sign character. Unlike the manual
|
|
// +/- key (toggleSign(), bound to the on-screen button) these are
|
|
// absolute rather than a toggle: a caliper transmission says which
|
|
// sign the reading is, it never flips one already on screen.
|
|
else if (e.key === '-') {
|
|
e.preventDefault();
|
|
this.negative = true;
|
|
}
|
|
else if (e.key === '+') {
|
|
e.preventDefault();
|
|
this.negative = false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Refuse the reading in progress: say why with an event the page can show,
|
|
* and wipe the display so the rejected digits do not linger as if valid.
|
|
* Further keystrokes of the same burst are ignored until Enter or a clear.
|
|
*/
|
|
_rejectReading(reason) {
|
|
this.$dispatch('numpad-error', { reason: reason });
|
|
this.value = '';
|
|
this.negative = false;
|
|
this.hasDecimal = false;
|
|
this._enterWasFast = false;
|
|
this._rejected = true;
|
|
},
|
|
|
|
/**
|
|
* Track keystroke timing for HID burst detection.
|
|
* USB calipers send digits in rapid succession (<80ms apart).
|
|
* Human typing is much slower (>100ms between keys).
|
|
*/
|
|
_trackBurst() {
|
|
const now = performance.now();
|
|
const gap = this._lastKeyTime > 0 ? now - this._lastKeyTime : Infinity;
|
|
|
|
if (gap < 80) {
|
|
this._burstCount++;
|
|
// The burst is only recognisable from its second character. If the
|
|
// pause before the first one was long enough to be a human keystroke,
|
|
// whatever was on display before this run began was typed by hand:
|
|
// drop it now instead of letting the caliper's own digits
|
|
// concatenate onto it.
|
|
if (this._burstCount === 2 && this._runStart && this._runStart.gap > 300) {
|
|
this.value = this.value.slice(this._runStart.value.length);
|
|
this.hasDecimal = this.value.includes('.');
|
|
}
|
|
} else {
|
|
this._burstCount = 1;
|
|
this._runStart = { value: this.value, gap: gap };
|
|
}
|
|
|
|
this._lastKeyTime = now;
|
|
}
|
|
};
|
|
}
|