Merge pull request #472 from pascalorg/feat/lingo-natural-inputs
feat: natural-language measurement inputs (UI + MCP tools) via @pascal-app/lingo
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
getLinearUnitLabel,
|
||||
linearUnitToMeters,
|
||||
metersToLinearUnit,
|
||||
} from '../../../lib/measurements'
|
||||
lingoUnitSpec,
|
||||
measurementHint,
|
||||
parseMeasurement,
|
||||
} from '../../../lib/measurement-parser'
|
||||
import { useLinearDisplay } from '../../../lib/use-linear-display'
|
||||
import { cn } from '../../../lib/utils'
|
||||
|
||||
interface MetricControlProps {
|
||||
@@ -37,19 +37,13 @@ export function MetricControl({
|
||||
unit = '',
|
||||
restoreOnCommit = true,
|
||||
}: MetricControlProps) {
|
||||
const viewerUnit = useViewer((state) => state.unit)
|
||||
const isImperial = viewerUnit === 'imperial' && unit === 'm'
|
||||
const displayUnit = isImperial ? getLinearUnitLabel('imperial') : unit
|
||||
const {
|
||||
isImperial,
|
||||
displayUnit,
|
||||
toDisplay: toDisplayValue,
|
||||
toStored: toStoredValue,
|
||||
} = useLinearDisplay(unit, precision)
|
||||
|
||||
const toDisplayValue = useCallback(
|
||||
(storedValue: number) => (isImperial ? metersToLinearUnit(storedValue, 'imperial') : storedValue),
|
||||
[isImperial],
|
||||
)
|
||||
const toStoredValue = useCallback(
|
||||
(displayValue: number) =>
|
||||
isImperial ? linearUnitToMeters(displayValue, 'imperial') : displayValue,
|
||||
[isImperial],
|
||||
)
|
||||
const clamp = useCallback(
|
||||
(val: number) => {
|
||||
return Math.min(Math.max(val, min), max)
|
||||
@@ -229,14 +223,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 +316,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,12 @@
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
lingoUnitSpec,
|
||||
measurementHint,
|
||||
parseMeasurement,
|
||||
} from '../../../lib/measurement-parser'
|
||||
import { useLinearDisplay } from '../../../lib/use-linear-display'
|
||||
import { cn } from '../../../lib/utils'
|
||||
|
||||
interface SliderControlProps {
|
||||
@@ -59,10 +65,17 @@ export function SliderControl({
|
||||
unit = '',
|
||||
restoreOnCommit = true,
|
||||
}: SliderControlProps) {
|
||||
// Display/storage conversion so the value honors the metric/imperial toggle.
|
||||
// `value`, `onChange`, `onCommit`, `min`/`max`/`clamp` are always in the
|
||||
// stored unit (meters for `unit === 'm'`); the step, drag deltas, text field
|
||||
// and rendered number are in the DISPLAY unit (feet when imperial). For
|
||||
// metric and non-length units these conversions are the identity.
|
||||
const { isImperial, displayUnit, toDisplay, toStored } = useLinearDisplay(unit, precision)
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(value.toFixed(precision))
|
||||
const [inputValue, setInputValue] = useState(toDisplay(value).toFixed(precision))
|
||||
|
||||
const dragRef = useRef<{
|
||||
// Original value at drag start — preserved across modifier re-anchors so
|
||||
@@ -80,12 +93,23 @@ export function SliderControl({
|
||||
valueRef.current = value
|
||||
|
||||
const clamp = useCallback((val: number) => Math.min(Math.max(val, min), max), [min, max])
|
||||
// Apply a signed display-unit delta to a stored value, rounding in the
|
||||
// display unit and clamping in the stored unit.
|
||||
const applyDisplayDelta = useCallback(
|
||||
(storedValue: number, displayDelta: number, displayStep: number) =>
|
||||
clamp(
|
||||
toStored(
|
||||
Number.parseFloat((toDisplay(storedValue) + displayDelta).toFixed(stepPrecision(displayStep))),
|
||||
),
|
||||
),
|
||||
[clamp, toDisplay, toStored],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setInputValue(value.toFixed(precision))
|
||||
setInputValue(toDisplay(value).toFixed(precision))
|
||||
}
|
||||
}, [value, precision, isEditing])
|
||||
}, [value, precision, isEditing, toDisplay])
|
||||
|
||||
// Wheel support on the label
|
||||
useEffect(() => {
|
||||
@@ -96,14 +120,13 @@ export function SliderControl({
|
||||
e.preventDefault()
|
||||
const direction = e.deltaY < 0 ? 1 : -1
|
||||
const s = getAdjustedStep(step, e)
|
||||
const newValue = clamp(valueRef.current + direction * s)
|
||||
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
|
||||
const final = applyDisplayDelta(valueRef.current, direction * s, s)
|
||||
if (final !== valueRef.current) onChange(final)
|
||||
onCommit?.(final)
|
||||
}
|
||||
el.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => el.removeEventListener('wheel', handleWheel)
|
||||
}, [isEditing, step, clamp, onChange, onCommit])
|
||||
}, [isEditing, step, applyDisplayDelta, onChange, onCommit])
|
||||
|
||||
// Arrow key support while hovered
|
||||
useEffect(() => {
|
||||
@@ -115,15 +138,14 @@ export function SliderControl({
|
||||
if (direction !== 0) {
|
||||
e.preventDefault()
|
||||
const s = getAdjustedStep(step, e)
|
||||
const newValue = clamp(valueRef.current + direction * s)
|
||||
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
|
||||
const final = applyDisplayDelta(valueRef.current, direction * s, s)
|
||||
if (final !== valueRef.current) onChange(final)
|
||||
onCommit?.(final)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isHovered, isEditing, step, clamp, onChange, onCommit])
|
||||
}, [isHovered, isEditing, step, applyDisplayDelta, onChange, onCommit])
|
||||
|
||||
const handleLabelPointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
@@ -160,15 +182,13 @@ export function SliderControl({
|
||||
const dx = e.clientX - anchorX
|
||||
const s = step * multiplier
|
||||
// 4 px per step at default sensitivity
|
||||
const newValue = clamp(
|
||||
Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))),
|
||||
)
|
||||
const newValue = applyDisplayDelta(anchorValue, (dx / 4) * s, s)
|
||||
if (newValue !== valueRef.current) {
|
||||
valueRef.current = newValue
|
||||
onChange(newValue)
|
||||
}
|
||||
},
|
||||
[step, clamp, onChange],
|
||||
[step, applyDisplayDelta, onChange],
|
||||
)
|
||||
|
||||
const handleLabelPointerUp = useCallback(
|
||||
@@ -195,49 +215,65 @@ export function SliderControl({
|
||||
|
||||
const handleValueClick = useCallback(() => {
|
||||
setIsEditing(true)
|
||||
setInputValue(value.toFixed(precision))
|
||||
}, [value, precision])
|
||||
setInputValue(toDisplay(value).toFixed(precision))
|
||||
}, [value, precision, toDisplay])
|
||||
|
||||
const submitValue = useCallback(() => {
|
||||
const numValue = Number.parseFloat(inputValue)
|
||||
if (Number.isNaN(numValue)) {
|
||||
setInputValue(value.toFixed(precision))
|
||||
const spec = lingoUnitSpec(unit)
|
||||
let stored = spec
|
||||
? parseMeasurement(inputValue, spec, {
|
||||
bareUnit: isImperial ? 'ft' : spec.unitId,
|
||||
system: isImperial ? 'us' : 'metric',
|
||||
})
|
||||
: null
|
||||
if (stored === null) {
|
||||
// Fallback: a bare number typed in the DISPLAY unit → convert to stored.
|
||||
const numValue = Number.parseFloat(inputValue)
|
||||
stored = Number.isFinite(numValue) ? toStored(numValue) : null
|
||||
}
|
||||
if (stored === null) {
|
||||
setInputValue(toDisplay(value).toFixed(precision))
|
||||
} else {
|
||||
const nextValue = clamp(Number.parseFloat(numValue.toFixed(precision)))
|
||||
const nextValue = clamp(toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))))
|
||||
onChange(nextValue)
|
||||
onCommit?.(nextValue)
|
||||
}
|
||||
setIsEditing(false)
|
||||
}, [inputValue, onChange, onCommit, clamp, precision, value])
|
||||
}, [inputValue, unit, isImperial, onChange, onCommit, clamp, precision, value, toDisplay, toStored])
|
||||
|
||||
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 handleInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
submitValue()
|
||||
} else if (e.key === 'Escape') {
|
||||
setInputValue(value.toFixed(precision))
|
||||
setInputValue(toDisplay(value).toFixed(precision))
|
||||
setIsEditing(false)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
const direction = e.key === 'ArrowUp' ? 1 : -1
|
||||
const adjustedStep = getAdjustedStep(step, e)
|
||||
const newV = clamp(
|
||||
Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(adjustedStep))),
|
||||
)
|
||||
const newV = applyDisplayDelta(value, direction * adjustedStep, adjustedStep)
|
||||
onChange(newV)
|
||||
setInputValue(newV.toFixed(precision))
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
const adjustedStep = getAdjustedStep(step, e)
|
||||
const newV = clamp(
|
||||
Number.parseFloat((value - adjustedStep).toFixed(stepPrecision(adjustedStep))),
|
||||
)
|
||||
onChange(newV)
|
||||
setInputValue(newV.toFixed(precision))
|
||||
setInputValue(toDisplay(newV).toFixed(precision))
|
||||
}
|
||||
},
|
||||
[submitValue, value, precision, step, clamp, onChange],
|
||||
[submitValue, value, precision, step, applyDisplayDelta, onChange, toDisplay],
|
||||
)
|
||||
|
||||
const displayValue = toDisplay(value)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -279,6 +315,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"
|
||||
@@ -288,7 +329,7 @@ export function SliderControl({
|
||||
type="text"
|
||||
value={inputValue}
|
||||
/>
|
||||
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
|
||||
{displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
@@ -296,9 +337,9 @@ export function SliderControl({
|
||||
onClick={handleValueClick}
|
||||
>
|
||||
<span className="font-mono tabular-nums tracking-tight" suppressHydrationWarning>
|
||||
{Number(value.toFixed(precision)).toFixed(precision)}
|
||||
{Number(displayValue.toFixed(precision)).toFixed(precision)}
|
||||
</span>
|
||||
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
|
||||
{displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,11 @@ import {
|
||||
} from './../../../../../lib/level-duplication'
|
||||
import { getDefaultLevelName } from '@pascal-app/core'
|
||||
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
|
||||
import {
|
||||
getLinearUnitLabel,
|
||||
linearUnitToMeters,
|
||||
metersToLinearUnit,
|
||||
} from './../../../../../lib/measurements'
|
||||
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
|
||||
import { cn } from './../../../../../lib/utils'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
@@ -93,6 +98,7 @@ const PropertyLineSection = memo(function PropertyLineSection() {
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const setMode = useEditor((state) => state.setMode)
|
||||
const viewerUnit = useViewer((state) => state.unit)
|
||||
|
||||
if (!siteNode) return null
|
||||
|
||||
@@ -101,6 +107,14 @@ const PropertyLineSection = memo(function PropertyLineSection() {
|
||||
const perimeter = calculatePerimeter(points)
|
||||
const isEditing = mode === 'edit'
|
||||
|
||||
// Property-line coordinates and readouts follow the metric/imperial toggle.
|
||||
const isImperial = viewerUnit === 'imperial'
|
||||
const linearLabel = getLinearUnitLabel(viewerUnit)
|
||||
const toDisplayLinear = (meters: number) => metersToLinearUnit(meters, viewerUnit)
|
||||
const toStoredLinear = (display: number) => linearUnitToMeters(display, viewerUnit)
|
||||
const displayArea = isImperial ? area * 10.763_910_417 : area
|
||||
const displayPerimeter = toDisplayLinear(perimeter)
|
||||
|
||||
const handleToggleEdit = () => {
|
||||
setMode(isEditing ? 'select' : 'edit')
|
||||
}
|
||||
@@ -166,10 +180,16 @@ const PropertyLineSection = memo(function PropertyLineSection() {
|
||||
{/* Measurements */}
|
||||
<div className="relative flex gap-3 pr-3 pb-2 pl-10">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Area: <span className="text-foreground">{area.toFixed(1)} m²</span>
|
||||
Area:{' '}
|
||||
<span className="text-foreground">
|
||||
{displayArea.toFixed(1)} {isImperial ? 'ft²' : 'm²'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Perimeter: <span className="text-foreground">{perimeter.toFixed(1)} m</span>
|
||||
Perimeter:{' '}
|
||||
<span className="text-foreground">
|
||||
{displayPerimeter.toFixed(1)} {linearLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -184,21 +204,21 @@ const PropertyLineSection = memo(function PropertyLineSection() {
|
||||
<input
|
||||
className="w-16 rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 text-foreground text-xs focus:border-primary focus:outline-none"
|
||||
onChange={(e) =>
|
||||
handlePointChange(index, 0, Number.parseFloat(e.target.value) || 0)
|
||||
handlePointChange(index, 0, toStoredLinear(Number.parseFloat(e.target.value) || 0))
|
||||
}
|
||||
step={0.5}
|
||||
type="number"
|
||||
value={point[0]}
|
||||
value={Number(toDisplayLinear(point[0]).toFixed(2))}
|
||||
/>
|
||||
<label className="shrink-0 text-muted-foreground">Z</label>
|
||||
<input
|
||||
className="w-16 rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 text-foreground text-xs focus:border-primary focus:outline-none"
|
||||
onChange={(e) =>
|
||||
handlePointChange(index, 1, Number.parseFloat(e.target.value) || 0)
|
||||
handlePointChange(index, 1, toStoredLinear(Number.parseFloat(e.target.value) || 0))
|
||||
}
|
||||
step={0.5}
|
||||
type="number"
|
||||
value={point[1]}
|
||||
value={Number(toDisplayLinear(point[1]).toFixed(2))}
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
|
||||
@@ -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 })}`
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback } from 'react'
|
||||
import { getLinearUnitLabel, linearUnitToMeters, metersToLinearUnit } from './measurements'
|
||||
|
||||
/**
|
||||
* Shared display/storage conversion for numeric property controls so that
|
||||
* every length input honors the metric/imperial toggle identically.
|
||||
*
|
||||
* Values are always STORED in the field's own unit (meters for `unit === 'm'`).
|
||||
* When the viewer preference is imperial AND the field is a meter length, the
|
||||
* value is DISPLAYED (and edited) in feet; otherwise the conversions are the
|
||||
* identity, so metric fields and non-length units (`'°'`, `'%'`, `'in'`, `''`,
|
||||
* …) behave exactly as before.
|
||||
*
|
||||
* Used by both `SliderControl` and `MetricControl` — keep the two in sync via
|
||||
* this single source of truth.
|
||||
*/
|
||||
export function useLinearDisplay(unit: string, precision: number) {
|
||||
const viewerUnit = useViewer((state) => state.unit)
|
||||
const isImperial = viewerUnit === 'imperial' && unit === 'm'
|
||||
const displayUnit = isImperial ? getLinearUnitLabel('imperial') : unit
|
||||
|
||||
const toDisplay = useCallback(
|
||||
(stored: number) => (isImperial ? metersToLinearUnit(stored, 'imperial') : stored),
|
||||
[isImperial],
|
||||
)
|
||||
const toStored = useCallback(
|
||||
(display: number) => (isImperial ? linearUnitToMeters(display, 'imperial') : display),
|
||||
[isImperial],
|
||||
)
|
||||
// Round a stored value so it lands on a clean number of DISPLAY-unit digits.
|
||||
const roundStored = useCallback(
|
||||
(stored: number) => toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))),
|
||||
[toDisplay, toStored, precision],
|
||||
)
|
||||
|
||||
return { isImperial, displayUnit, toDisplay, toStored, roundStored }
|
||||
}
|
||||
Reference in New Issue
Block a user