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 })}` +}