Aymeric Rabot a5963560b1 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>
2026-07-08 12:04:14 +02:00

329 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
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 {
label: React.ReactNode
value: number
onChange: (value: number) => void
onCommit?: (value: number) => void
min?: number
max?: number
precision?: number
step?: number
className?: string
unit?: string
restoreOnCommit?: boolean
}
function stepPrecision(s: number): number {
if (s <= 0) return 0
return Math.max(0, Math.ceil(-Math.log10(s)))
}
function getStepMultiplier(modifiers: {
shiftKey?: boolean
metaKey?: boolean
ctrlKey?: boolean
altKey?: boolean
}): number {
if (modifiers.shiftKey) return 10
if (modifiers.metaKey || modifiers.ctrlKey || modifiers.altKey) return 0.1
return 1
}
function getAdjustedStep(
baseStep: number,
modifiers: {
shiftKey?: boolean
metaKey?: boolean
ctrlKey?: boolean
altKey?: boolean
},
): number {
return baseStep * getStepMultiplier(modifiers)
}
export function SliderControl({
label,
value,
onChange,
onCommit,
min = Number.NEGATIVE_INFINITY,
max = Number.POSITIVE_INFINITY,
precision = 0,
step = 1,
className,
unit = '',
restoreOnCommit = true,
}: SliderControlProps) {
const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [inputValue, setInputValue] = useState(value.toFixed(precision))
const dragRef = useRef<{
// Original value at drag start — preserved across modifier re-anchors so
// undo/redo rolls back to the pre-drag state, not to a mid-drag anchor.
originValue: number
// Anchor pointer position and value — updated whenever modifier keys
// change so the delta calculation continues smoothly from the current
// position at the new step size.
anchorX: number
anchorValue: number
stepMultiplier: number
} | null>(null)
const labelRef = useRef<HTMLDivElement>(null)
const valueRef = useRef(value)
valueRef.current = value
const clamp = useCallback((val: number) => Math.min(Math.max(val, min), max), [min, max])
useEffect(() => {
if (!isEditing) {
setInputValue(value.toFixed(precision))
}
}, [value, precision, isEditing])
// Wheel support on the label
useEffect(() => {
const el = labelRef.current
if (!el) return
const handleWheel = (e: WheelEvent) => {
if (isEditing) return
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)))
if (final !== valueRef.current) onChange(final)
onCommit?.(final)
}
el.addEventListener('wheel', handleWheel, { passive: false })
return () => el.removeEventListener('wheel', handleWheel)
}, [isEditing, step, clamp, onChange, onCommit])
// Arrow key support while hovered
useEffect(() => {
if (!isHovered || isEditing) return
const handleKeyDown = (e: KeyboardEvent) => {
let direction = 0
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') direction = 1
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1
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)))
if (final !== valueRef.current) onChange(final)
onCommit?.(final)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isHovered, isEditing, step, clamp, onChange, onCommit])
const handleLabelPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (isEditing) return
e.preventDefault()
e.currentTarget.setPointerCapture(e.pointerId)
dragRef.current = {
originValue: valueRef.current,
anchorX: e.clientX,
anchorValue: valueRef.current,
stepMultiplier: getStepMultiplier(e),
}
setIsDragging(true)
useScene.temporal.getState().pause()
},
[isEditing],
)
const handleLabelPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return
const multiplier = getStepMultiplier(e)
// If modifier keys changed mid-drag, re-anchor from the current pointer
// position and value — otherwise the accumulated dx would be applied
// with a new step size and jump the value (e.g. pressing Cmd while
// already far from the starting point would snap back toward it).
if (multiplier !== dragRef.current.stepMultiplier) {
dragRef.current.anchorX = e.clientX
dragRef.current.anchorValue = valueRef.current
dragRef.current.stepMultiplier = multiplier
return
}
const { anchorX, anchorValue } = dragRef.current
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))),
)
if (newValue !== valueRef.current) {
valueRef.current = newValue
onChange(newValue)
}
},
[step, clamp, onChange],
)
const handleLabelPointerUp = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return
const { originValue } = dragRef.current
const finalVal = valueRef.current
dragRef.current = null
setIsDragging(false)
e.currentTarget.releasePointerCapture(e.pointerId)
if (originValue !== finalVal && restoreOnCommit) {
onChange(originValue)
useScene.temporal.getState().resume()
onChange(finalVal)
onCommit?.(finalVal)
} else {
useScene.temporal.getState().resume()
onCommit?.(finalVal)
}
},
[onChange, onCommit, restoreOnCommit],
)
const handleValueClick = useCallback(() => {
setIsEditing(true)
setInputValue(value.toFixed(precision))
}, [value, precision])
const submitValue = useCallback(() => {
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(parsed.toFixed(precision)))
onChange(nextValue)
onCommit?.(nextValue)
}
setIsEditing(false)
}, [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>) => {
if (e.key === 'Enter') {
submitValue()
} else if (e.key === 'Escape') {
setInputValue(value.toFixed(precision))
setIsEditing(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const adjustedStep = getAdjustedStep(step, e)
const newV = clamp(
Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(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))
}
},
[submitValue, value, precision, step, clamp, onChange],
)
return (
<div
className={cn(
'group flex h-7 w-full select-none items-center rounded-lg px-2 transition-colors',
isDragging ? 'bg-white/5' : 'hover:bg-white/5',
className,
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Label — drag handle */}
<div
className={cn(
'flex shrink-0 cursor-ew-resize items-center gap-1.5 text-xs transition-colors',
isDragging ? 'text-foreground' : 'text-muted-foreground hover:text-foreground/80',
)}
onPointerDown={handleLabelPointerDown}
onPointerMove={handleLabelPointerMove}
onPointerUp={handleLabelPointerUp}
ref={labelRef}
>
{/* Grip dots — 2×3 grid */}
<div
className={cn(
'grid grid-cols-2 gap-[2.5px] transition-opacity',
isDragging ? 'opacity-70' : 'opacity-25 group-hover:opacity-50',
)}
>
{[...Array(6)].map((_, i) => (
<div className="h-[2px] w-[2px] rounded-full bg-current" key={i} />
))}
</div>
<span className="font-medium">{label}</span>
</div>
<div className="flex-1" />
{/* Value — click to edit */}
<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"
onBlur={submitValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleInputKeyDown}
type="text"
value={inputValue}
/>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
</>
) : (
<div
className="flex cursor-text items-center text-foreground/60 transition-colors hover:text-foreground"
onClick={handleValueClick}
>
<span className="font-mono tabular-nums tracking-tight" suppressHydrationWarning>
{Number(value.toFixed(precision)).toFixed(precision)}
</span>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
</div>
)}
</div>
</div>
)
}