From 69813a6e62c4f5d83e449510d3e194a6ef71b719 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 8 Jul 2026 17:00:47 +0200 Subject: [PATCH] feat(editor): honor the metric/imperial toggle in every length input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../components/ui/controls/metric-control.tsx | 25 ++--- .../components/ui/controls/slider-control.tsx | 102 +++++++++++------- packages/editor/src/lib/use-linear-display.ts | 40 +++++++ 3 files changed, 108 insertions(+), 59 deletions(-) create mode 100644 packages/editor/src/lib/use-linear-display.ts diff --git a/packages/editor/src/components/ui/controls/metric-control.tsx b/packages/editor/src/components/ui/controls/metric-control.tsx index f9336eb7..9742c813 100644 --- a/packages/editor/src/components/ui/controls/metric-control.tsx +++ b/packages/editor/src/components/ui/controls/metric-control.tsx @@ -1,18 +1,13 @@ 'use client' 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, - metersToLinearUnit, -} from '../../../lib/measurements' +import { useLinearDisplay } from '../../../lib/use-linear-display' import { cn } from '../../../lib/utils' interface MetricControlProps { @@ -42,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) diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx index 36386eef..c902cecd 100644 --- a/packages/editor/src/components/ui/controls/slider-control.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.tsx @@ -7,6 +7,7 @@ import { measurementHint, parseMeasurement, } from '../../../lib/measurement-parser' +import { useLinearDisplay } from '../../../lib/use-linear-display' import { cn } from '../../../lib/utils' interface SliderControlProps { @@ -64,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 @@ -85,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(() => { @@ -101,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(() => { @@ -120,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) => { @@ -165,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( @@ -200,30 +215,42 @@ 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 spec = lingoUnitSpec(unit) - let parsed = spec ? parseMeasurement(inputValue, spec) : null - if (parsed === null) { + 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) - parsed = Number.isFinite(numValue) ? numValue : null + stored = Number.isFinite(numValue) ? toStored(numValue) : null } - if (parsed === null) { - setInputValue(value.toFixed(precision)) + if (stored === null) { + setInputValue(toDisplay(value).toFixed(precision)) } else { - const nextValue = clamp(Number.parseFloat(parsed.toFixed(precision))) + const nextValue = clamp(toStored(Number.parseFloat(toDisplay(stored).toFixed(precision)))) onChange(nextValue) onCommit?.(nextValue) } setIsEditing(false) - }, [inputValue, unit, 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, { 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 const handleInputKeyDown = useCallback( @@ -231,29 +258,22 @@ export function SliderControl({ 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 (
- {unit && {unit}} + {displayUnit && {displayUnit}} ) : (
- {Number(value.toFixed(precision)).toFixed(precision)} + {Number(displayValue.toFixed(precision)).toFixed(precision)} - {unit && {unit}} + {displayUnit && {displayUnit}}
)}
diff --git a/packages/editor/src/lib/use-linear-display.ts b/packages/editor/src/lib/use-linear-display.ts new file mode 100644 index 00000000..6578ea30 --- /dev/null +++ b/packages/editor/src/lib/use-linear-display.ts @@ -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 } +}