feat(editor): honor the metric/imperial toggle in every length input
SliderControl (which drives the auto-inspector's ~144 fields and most custom panels) previously ignored the viewer unit preference and always rendered raw meters with a static "m" label, while MetricControl converted to feet — so toggling imperial changed some length inputs and not others. Extract the conversion into a shared `useLinearDisplay(unit, precision)` hook and route BOTH controls through it, so every `unit="m"` length field displays and edits in feet when imperial and meters when metric — consistently. Values are always stored in meters; only display, the text field, and drag/ wheel/arrow deltas move to the display unit. For metric and non-length units (°, %, in, …) the conversions are the identity, so those paths are unchanged. 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
af1fe6a34c
commit
69813a6e62
@@ -1,18 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useScene } from '@pascal-app/core'
|
import { useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
lingoUnitSpec,
|
lingoUnitSpec,
|
||||||
measurementHint,
|
measurementHint,
|
||||||
parseMeasurement,
|
parseMeasurement,
|
||||||
} from '../../../lib/measurement-parser'
|
} from '../../../lib/measurement-parser'
|
||||||
import {
|
import { useLinearDisplay } from '../../../lib/use-linear-display'
|
||||||
getLinearUnitLabel,
|
|
||||||
linearUnitToMeters,
|
|
||||||
metersToLinearUnit,
|
|
||||||
} from '../../../lib/measurements'
|
|
||||||
import { cn } from '../../../lib/utils'
|
import { cn } from '../../../lib/utils'
|
||||||
|
|
||||||
interface MetricControlProps {
|
interface MetricControlProps {
|
||||||
@@ -42,19 +37,13 @@ export function MetricControl({
|
|||||||
unit = '',
|
unit = '',
|
||||||
restoreOnCommit = true,
|
restoreOnCommit = true,
|
||||||
}: MetricControlProps) {
|
}: MetricControlProps) {
|
||||||
const viewerUnit = useViewer((state) => state.unit)
|
const {
|
||||||
const isImperial = viewerUnit === 'imperial' && unit === 'm'
|
isImperial,
|
||||||
const displayUnit = isImperial ? getLinearUnitLabel('imperial') : unit
|
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(
|
const clamp = useCallback(
|
||||||
(val: number) => {
|
(val: number) => {
|
||||||
return Math.min(Math.max(val, min), max)
|
return Math.min(Math.max(val, min), max)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
measurementHint,
|
measurementHint,
|
||||||
parseMeasurement,
|
parseMeasurement,
|
||||||
} from '../../../lib/measurement-parser'
|
} from '../../../lib/measurement-parser'
|
||||||
|
import { useLinearDisplay } from '../../../lib/use-linear-display'
|
||||||
import { cn } from '../../../lib/utils'
|
import { cn } from '../../../lib/utils'
|
||||||
|
|
||||||
interface SliderControlProps {
|
interface SliderControlProps {
|
||||||
@@ -64,10 +65,17 @@ export function SliderControl({
|
|||||||
unit = '',
|
unit = '',
|
||||||
restoreOnCommit = true,
|
restoreOnCommit = true,
|
||||||
}: SliderControlProps) {
|
}: 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 [isEditing, setIsEditing] = useState(false)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const [isHovered, setIsHovered] = 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<{
|
const dragRef = useRef<{
|
||||||
// Original value at drag start — preserved across modifier re-anchors so
|
// Original value at drag start — preserved across modifier re-anchors so
|
||||||
@@ -85,12 +93,23 @@ export function SliderControl({
|
|||||||
valueRef.current = value
|
valueRef.current = value
|
||||||
|
|
||||||
const clamp = useCallback((val: number) => Math.min(Math.max(val, min), max), [min, max])
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
setInputValue(value.toFixed(precision))
|
setInputValue(toDisplay(value).toFixed(precision))
|
||||||
}
|
}
|
||||||
}, [value, precision, isEditing])
|
}, [value, precision, isEditing, toDisplay])
|
||||||
|
|
||||||
// Wheel support on the label
|
// Wheel support on the label
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -101,14 +120,13 @@ export function SliderControl({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const direction = e.deltaY < 0 ? 1 : -1
|
const direction = e.deltaY < 0 ? 1 : -1
|
||||||
const s = getAdjustedStep(step, e)
|
const s = getAdjustedStep(step, e)
|
||||||
const newValue = clamp(valueRef.current + direction * s)
|
const final = applyDisplayDelta(valueRef.current, direction * s, s)
|
||||||
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
|
|
||||||
if (final !== valueRef.current) onChange(final)
|
if (final !== valueRef.current) onChange(final)
|
||||||
onCommit?.(final)
|
onCommit?.(final)
|
||||||
}
|
}
|
||||||
el.addEventListener('wheel', handleWheel, { passive: false })
|
el.addEventListener('wheel', handleWheel, { passive: false })
|
||||||
return () => el.removeEventListener('wheel', handleWheel)
|
return () => el.removeEventListener('wheel', handleWheel)
|
||||||
}, [isEditing, step, clamp, onChange, onCommit])
|
}, [isEditing, step, applyDisplayDelta, onChange, onCommit])
|
||||||
|
|
||||||
// Arrow key support while hovered
|
// Arrow key support while hovered
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -120,15 +138,14 @@ export function SliderControl({
|
|||||||
if (direction !== 0) {
|
if (direction !== 0) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const s = getAdjustedStep(step, e)
|
const s = getAdjustedStep(step, e)
|
||||||
const newValue = clamp(valueRef.current + direction * s)
|
const final = applyDisplayDelta(valueRef.current, direction * s, s)
|
||||||
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
|
|
||||||
if (final !== valueRef.current) onChange(final)
|
if (final !== valueRef.current) onChange(final)
|
||||||
onCommit?.(final)
|
onCommit?.(final)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [isHovered, isEditing, step, clamp, onChange, onCommit])
|
}, [isHovered, isEditing, step, applyDisplayDelta, onChange, onCommit])
|
||||||
|
|
||||||
const handleLabelPointerDown = useCallback(
|
const handleLabelPointerDown = useCallback(
|
||||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
@@ -165,15 +182,13 @@ export function SliderControl({
|
|||||||
const dx = e.clientX - anchorX
|
const dx = e.clientX - anchorX
|
||||||
const s = step * multiplier
|
const s = step * multiplier
|
||||||
// 4 px per step at default sensitivity
|
// 4 px per step at default sensitivity
|
||||||
const newValue = clamp(
|
const newValue = applyDisplayDelta(anchorValue, (dx / 4) * s, s)
|
||||||
Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))),
|
|
||||||
)
|
|
||||||
if (newValue !== valueRef.current) {
|
if (newValue !== valueRef.current) {
|
||||||
valueRef.current = newValue
|
valueRef.current = newValue
|
||||||
onChange(newValue)
|
onChange(newValue)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[step, clamp, onChange],
|
[step, applyDisplayDelta, onChange],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleLabelPointerUp = useCallback(
|
const handleLabelPointerUp = useCallback(
|
||||||
@@ -200,30 +215,42 @@ export function SliderControl({
|
|||||||
|
|
||||||
const handleValueClick = useCallback(() => {
|
const handleValueClick = useCallback(() => {
|
||||||
setIsEditing(true)
|
setIsEditing(true)
|
||||||
setInputValue(value.toFixed(precision))
|
setInputValue(toDisplay(value).toFixed(precision))
|
||||||
}, [value, precision])
|
}, [value, precision, toDisplay])
|
||||||
|
|
||||||
const submitValue = useCallback(() => {
|
const submitValue = useCallback(() => {
|
||||||
const spec = lingoUnitSpec(unit)
|
const spec = lingoUnitSpec(unit)
|
||||||
let parsed = spec ? parseMeasurement(inputValue, spec) : null
|
let stored = spec
|
||||||
if (parsed === null) {
|
? 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)
|
const numValue = Number.parseFloat(inputValue)
|
||||||
parsed = Number.isFinite(numValue) ? numValue : null
|
stored = Number.isFinite(numValue) ? toStored(numValue) : null
|
||||||
}
|
}
|
||||||
if (parsed === null) {
|
if (stored === null) {
|
||||||
setInputValue(value.toFixed(precision))
|
setInputValue(toDisplay(value).toFixed(precision))
|
||||||
} else {
|
} else {
|
||||||
const nextValue = clamp(Number.parseFloat(parsed.toFixed(precision)))
|
const nextValue = clamp(toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))))
|
||||||
onChange(nextValue)
|
onChange(nextValue)
|
||||||
onCommit?.(nextValue)
|
onCommit?.(nextValue)
|
||||||
}
|
}
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
}, [inputValue, unit, onChange, onCommit, clamp, precision, value])
|
}, [inputValue, unit, isImperial, onChange, onCommit, clamp, precision, value, toDisplay, toStored])
|
||||||
|
|
||||||
const spec = lingoUnitSpec(unit)
|
const spec = lingoUnitSpec(unit)
|
||||||
const hint =
|
const hint =
|
||||||
isEditing && spec
|
isEditing && spec
|
||||||
? measurementHint(inputValue, spec, { displayUnit: spec.unitId, precision, clamp })
|
? measurementHint(inputValue, spec, {
|
||||||
|
bareUnit: isImperial ? 'ft' : spec.unitId,
|
||||||
|
system: isImperial ? 'us' : 'metric',
|
||||||
|
displayUnit: isImperial ? 'ft' : spec.unitId,
|
||||||
|
precision,
|
||||||
|
clamp,
|
||||||
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const handleInputKeyDown = useCallback(
|
const handleInputKeyDown = useCallback(
|
||||||
@@ -231,29 +258,22 @@ export function SliderControl({
|
|||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
submitValue()
|
submitValue()
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
setInputValue(value.toFixed(precision))
|
setInputValue(toDisplay(value).toFixed(precision))
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
const direction = e.key === 'ArrowUp' ? 1 : -1
|
||||||
const adjustedStep = getAdjustedStep(step, e)
|
const adjustedStep = getAdjustedStep(step, e)
|
||||||
const newV = clamp(
|
const newV = applyDisplayDelta(value, direction * adjustedStep, adjustedStep)
|
||||||
Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(adjustedStep))),
|
|
||||||
)
|
|
||||||
onChange(newV)
|
onChange(newV)
|
||||||
setInputValue(newV.toFixed(precision))
|
setInputValue(toDisplay(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))
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[submitValue, value, precision, step, clamp, onChange],
|
[submitValue, value, precision, step, applyDisplayDelta, onChange, toDisplay],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const displayValue = toDisplay(value)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -309,7 +329,7 @@ export function SliderControl({
|
|||||||
type="text"
|
type="text"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
/>
|
/>
|
||||||
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
|
{displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
@@ -317,9 +337,9 @@ export function SliderControl({
|
|||||||
onClick={handleValueClick}
|
onClick={handleValueClick}
|
||||||
>
|
>
|
||||||
<span className="font-mono tabular-nums tracking-tight" suppressHydrationWarning>
|
<span className="font-mono tabular-nums tracking-tight" suppressHydrationWarning>
|
||||||
{Number(value.toFixed(precision)).toFixed(precision)}
|
{Number(displayValue.toFixed(precision)).toFixed(precision)}
|
||||||
</span>
|
</span>
|
||||||
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
|
{displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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