feat(editor): natural-language measurement inputs via @pascal-app/lingo
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
51fddc2d9c
commit
a5963560b1
@@ -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",
|
||||
|
||||
@@ -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 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)
|
||||
if (Number.isNaN(numValue)) {
|
||||
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({
|
||||
<div className="flex shrink-0 justify-end">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center">
|
||||
{hint && (
|
||||
<span className="mr-1.5 shrink-0 whitespace-nowrap text-[11px] text-muted-foreground/50 tabular-nums">
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
lingoUnitSpec,
|
||||
measurementHint,
|
||||
parseMeasurement,
|
||||
} from '../../../lib/measurement-parser'
|
||||
import { cn } from '../../../lib/utils'
|
||||
|
||||
interface SliderControlProps {
|
||||
@@ -199,16 +204,27 @@ export function SliderControl({
|
||||
}, [value, precision])
|
||||
|
||||
const submitValue = useCallback(() => {
|
||||
const spec = lingoUnitSpec(unit)
|
||||
let parsed = spec ? parseMeasurement(inputValue, spec) : null
|
||||
if (parsed === null) {
|
||||
const numValue = Number.parseFloat(inputValue)
|
||||
if (Number.isNaN(numValue)) {
|
||||
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<HTMLInputElement>) => {
|
||||
@@ -279,6 +295,11 @@ export function SliderControl({
|
||||
<div className="flex items-center text-xs">
|
||||
{isEditing ? (
|
||||
<>
|
||||
{hint && (
|
||||
<span className="mr-1 shrink-0 whitespace-nowrap text-[10px] text-muted-foreground/50 tabular-nums">
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
autoFocus
|
||||
className="w-14 bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { type Kind, parseQuantity, quantity } from '@pascal-app/lingo'
|
||||
|
||||
/**
|
||||
* Natural-language measurement parsing for editor property fields, backed by
|
||||
* `@pascal-app/lingo`. Lets a user type `6ft`, `180cm`, `1m80`, `5'11"`, `45°`
|
||||
* or `1.57rad` into any measurement field and have it canonicalized to the
|
||||
* unit the field stores its value in — independent of the metric/imperial
|
||||
* display toggle.
|
||||
*
|
||||
* The editor stores linear values in meters and angular values in radians (a
|
||||
* few fields store inches or degrees); lingo's `length` base is meters and its
|
||||
* `angle` base is radians, so a field's `unit` prop already names the stored /
|
||||
* canonical unit and doubles as the parse target.
|
||||
*/
|
||||
|
||||
export interface LingoUnitSpec {
|
||||
kind: Kind
|
||||
/** Unit id the field's stored `value` is expressed in — the parse target. */
|
||||
unitId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an editor field `unit` prop to a lingo kind + canonical unit. Returns
|
||||
* `null` for units we deliberately do NOT natural-language-parse (plain
|
||||
* numbers, percentages, angular rates, counts) so those keep exact
|
||||
* `Number.parseFloat` behavior.
|
||||
*/
|
||||
export function lingoUnitSpec(unit: string | undefined): LingoUnitSpec | null {
|
||||
switch (unit) {
|
||||
case 'm':
|
||||
case 'cm':
|
||||
case 'mm':
|
||||
case 'in':
|
||||
case 'ft':
|
||||
return { kind: 'length', unitId: unit }
|
||||
case '°':
|
||||
case 'deg':
|
||||
case 'degrees':
|
||||
return { kind: 'angle', unitId: 'deg' }
|
||||
case 'rad':
|
||||
case 'radians':
|
||||
return { kind: 'angle', unitId: 'rad' }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export interface ParseMeasurementOptions {
|
||||
/**
|
||||
* Implied unit for a BARE number (e.g. `6` → `6 ft`). Defaults to the field's
|
||||
* own `unitId`. A typed unit (`180cm`) is always honored regardless.
|
||||
*/
|
||||
bareUnit?: string
|
||||
/** Disambiguates gal/ton/cup families; harmless for length/angle. */
|
||||
system?: 'metric' | 'us' | 'imperial'
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse typed field text into the field's stored numeric unit. Returns `null`
|
||||
* when the text can't be read as a quantity, so the caller can fall back to a
|
||||
* plain number parse or revert to the previous value.
|
||||
*/
|
||||
export function parseMeasurement(
|
||||
raw: string,
|
||||
spec: LingoUnitSpec,
|
||||
options: ParseMeasurementOptions = {},
|
||||
): number | null {
|
||||
const result = parseQuantity(raw, {
|
||||
kind: spec.kind,
|
||||
unit: options.bareUnit ?? spec.unitId,
|
||||
system: options.system,
|
||||
strictness: 'forgiving',
|
||||
})
|
||||
if (!result.ok) return null
|
||||
const value = result.quantity.to(spec.unitId).value
|
||||
return Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
export interface MeasurementHintOptions extends ParseMeasurementOptions {
|
||||
/** Unit id the preview is rendered in (the field's displayed unit). */
|
||||
displayUnit?: string
|
||||
/** Max fraction digits in the preview. */
|
||||
precision?: number
|
||||
/**
|
||||
* Clamp applied to the parsed stored value before previewing, so the hint
|
||||
* reflects what a commit would actually store on a bounded field.
|
||||
*/
|
||||
clamp?: (stored: number) => 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 })}`
|
||||
}
|
||||
Reference in New Issue
Block a user