From a5963560b168806ed74a9f8406c5b796e3863eb9 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 8 Jul 2026 12:04:14 +0200 Subject: [PATCH] feat(editor): natural-language measurement inputs via @pascal-app/lingo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type any unit into SliderControl / MetricControl fields — "6ft", "180cm", "1m80", "5'11\"", "72in", "45°", "1.57rad" — and it canonicalizes to the unit the field already stores (meters / degrees / radians / inches), independent of the metric/imperial display toggle. A live "= 1.83 m" hint previews the parsed value while typing. Only the typed-text commit path changes: drag-scrub, wheel, and arrow-key editing are untouched, and any unit the adapter doesn't recognise (%, unitless, rad/s) falls back to Number.parseFloat, so nothing regresses. The shared lib/measurement-parser.ts maps each field's `unit` prop to a lingo kind + canonical unit, covering the auto-inspector and every custom panel at once. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/editor/package.json | 1 + .../components/ui/controls/metric-control.tsx | 50 +++++++- .../components/ui/controls/slider-control.tsx | 29 ++++- packages/editor/src/lib/measurement-parser.ts | 116 ++++++++++++++++++ 4 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 packages/editor/src/lib/measurement-parser.ts diff --git a/packages/editor/package.json b/packages/editor/package.json index 02ead606..481ab352 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -26,6 +26,7 @@ "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", + "@pascal-app/lingo": "^0.1.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/editor/src/components/ui/controls/metric-control.tsx b/packages/editor/src/components/ui/controls/metric-control.tsx index 1ce1f5fc..f9336eb7 100644 --- a/packages/editor/src/components/ui/controls/metric-control.tsx +++ b/packages/editor/src/components/ui/controls/metric-control.tsx @@ -3,6 +3,11 @@ import { useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' +import { + lingoUnitSpec, + measurementHint, + parseMeasurement, +} from '../../../lib/measurement-parser' import { getLinearUnitLabel, linearUnitToMeters, @@ -229,14 +234,46 @@ export function MetricControl({ }, []) const submitValue = useCallback(() => { - const numValue = Number.parseFloat(inputValue) - if (Number.isNaN(numValue)) { + const spec = lingoUnitSpec(unit) + let stored = spec + ? parseMeasurement(inputValue, spec, { + bareUnit: isImperial ? 'ft' : spec.unitId, + system: isImperial ? 'us' : 'metric', + }) + : null + if (stored === null) { + const numValue = Number.parseFloat(inputValue) + stored = Number.isFinite(numValue) ? toStoredValue(numValue) : null + } + if (stored === null) { setInputValue(toDisplayValue(value).toFixed(precision)) } else { - applyCommittedValue(clamp(toStoredValue(numValue))) + applyCommittedValue(clamp(stored)) } setIsEditing(false) - }, [inputValue, applyCommittedValue, clamp, toStoredValue, value, precision, toDisplayValue]) + }, [ + inputValue, + unit, + isImperial, + applyCommittedValue, + clamp, + toStoredValue, + value, + precision, + toDisplayValue, + ]) + + const spec = lingoUnitSpec(unit) + const hint = + isEditing && spec + ? measurementHint(inputValue, spec, { + bareUnit: isImperial ? 'ft' : spec.unitId, + system: isImperial ? 'us' : 'metric', + displayUnit: isImperial ? 'ft' : spec.unitId, + precision, + clamp, + }) + : null const handleInputBlur = useCallback(() => { submitValue() @@ -290,6 +327,11 @@ export function MetricControl({
{isEditing ? (
+ {hint && ( + + {hint} + + )} { - const numValue = Number.parseFloat(inputValue) - if (Number.isNaN(numValue)) { + const spec = lingoUnitSpec(unit) + let parsed = spec ? parseMeasurement(inputValue, spec) : null + if (parsed === null) { + const numValue = Number.parseFloat(inputValue) + parsed = Number.isFinite(numValue) ? numValue : null + } + if (parsed === null) { setInputValue(value.toFixed(precision)) } else { - const nextValue = clamp(Number.parseFloat(numValue.toFixed(precision))) + const nextValue = clamp(Number.parseFloat(parsed.toFixed(precision))) onChange(nextValue) onCommit?.(nextValue) } setIsEditing(false) - }, [inputValue, onChange, onCommit, clamp, precision, value]) + }, [inputValue, unit, onChange, onCommit, clamp, precision, value]) + + const spec = lingoUnitSpec(unit) + const hint = + isEditing && spec + ? measurementHint(inputValue, spec, { displayUnit: spec.unitId, precision, clamp }) + : null const handleInputKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -279,6 +295,11 @@ export function SliderControl({
{isEditing ? ( <> + {hint && ( + + {hint} + + )} number +} + +const PLAIN_NUMBER = /^[+-]?\d*\.?\d*$/ + +/** + * A faint "= 1.83 m" preview of the value currently being typed, shown only + * when the user typed something beyond a plain decimal in the field's own unit + * (an explicit unit, a compound like `1m80`, or a number word) and it parses. + * Returns `null` when there's nothing useful to preview. + */ +export function measurementHint( + raw: string, + spec: LingoUnitSpec, + options: MeasurementHintOptions = {}, +): string | null { + const trimmed = raw.trim() + if (!trimmed || PLAIN_NUMBER.test(trimmed)) return null + + const parsed = parseMeasurement(trimmed, spec, options) + if (parsed === null) return null + const stored = options.clamp ? options.clamp(parsed) : parsed + + const displayUnit = options.displayUnit ?? spec.unitId + const precision = options.precision ?? 2 + const q = quantity(stored, spec.unitId) + const shown = displayUnit === spec.unitId ? q : q.to(displayUnit) + return `= ${shown.format({ precision })}` +}