/** * 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; } }; }