Skip to content
Open
7 changes: 0 additions & 7 deletions .env.example

This file was deleted.

80 changes: 53 additions & 27 deletions packages/editor/src/components/ui/controls/slider-control.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
'use client'

import { useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { formatMeasurement, parseMeasurement } from '../../../lib/measurements'
import { cn } from '../../../lib/utils'

interface SliderControlProps {
Expand Down Expand Up @@ -59,10 +61,25 @@ export function SliderControl({
unit = '',
restoreOnCommit = true,
}: SliderControlProps) {
const viewerUnit = useViewer((state) => state.unit)
const isLengthMeasurement = unit === 'm'
const activeUnit = isLengthMeasurement ? viewerUnit : 'metric'
const multiplier = isLengthMeasurement && activeUnit === 'imperial' ? 3.28084 : 1
const displayUnit = isLengthMeasurement && activeUnit === 'imperial' ? '' : unit

const getDisplayStr = useCallback(
(v: number) => {
return isLengthMeasurement && activeUnit === 'imperial'
? formatMeasurement(v, 'imperial', precision)
: v.toFixed(precision)
},
[isLengthMeasurement, activeUnit, 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(getDisplayStr(value))

const dragRef = useRef<{
// Original value at drag start — preserved across modifier re-anchors so
Expand All @@ -83,9 +100,9 @@ export function SliderControl({

useEffect(() => {
if (!isEditing) {
setInputValue(value.toFixed(precision))
setInputValue(getDisplayStr(value))
}
}, [value, precision, isEditing])
}, [value, getDisplayStr, isEditing])

// Wheel support on the label
useEffect(() => {
Expand All @@ -95,7 +112,7 @@ export function SliderControl({
if (isEditing) return
e.preventDefault()
const direction = e.deltaY < 0 ? 1 : -1
const s = getAdjustedStep(step, e)
const s = getAdjustedStep(step / multiplier, e)
const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
if (final !== valueRef.current) onChange(final)
Expand All @@ -114,7 +131,7 @@ export function SliderControl({
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1
if (direction !== 0) {
e.preventDefault()
const s = getAdjustedStep(step, e)
const s = getAdjustedStep(step / multiplier, e)
const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
if (final !== valueRef.current) onChange(final)
Expand Down Expand Up @@ -145,20 +162,20 @@ export function SliderControl({
const handleLabelPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return
const multiplier = getStepMultiplier(e)
const multiplier_val = 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) {
if (multiplier_val !== dragRef.current.stepMultiplier) {
dragRef.current.anchorX = e.clientX
dragRef.current.anchorValue = valueRef.current
dragRef.current.stepMultiplier = multiplier
dragRef.current.stepMultiplier = multiplier_val
return
}
const { anchorX, anchorValue } = dragRef.current
const dx = e.clientX - anchorX
const s = step * multiplier
const s = (step / multiplier) * multiplier_val
// 4 px per step at default sensitivity
const newValue = clamp(
Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))),
Expand Down Expand Up @@ -195,47 +212,54 @@ export function SliderControl({

const handleValueClick = useCallback(() => {
setIsEditing(true)
setInputValue(value.toFixed(precision))
}, [value, precision])
setInputValue(getDisplayStr(value))
}, [value, getDisplayStr])

const submitValue = useCallback(() => {
const numValue = Number.parseFloat(inputValue)
if (Number.isNaN(numValue)) {
setInputValue(value.toFixed(precision))
let nextValue: number | null = null
if (isLengthMeasurement && activeUnit === 'imperial') {
nextValue = parseMeasurement(inputValue, 'imperial')
} else {
const numValue = Number.parseFloat(inputValue)
nextValue = Number.isNaN(numValue) ? null : numValue
}

if (nextValue === null) {
setInputValue(getDisplayStr(value))
} else {
const nextValue = clamp(Number.parseFloat(numValue.toFixed(precision)))
onChange(nextValue)
onCommit?.(nextValue)
const clamped = clamp(Number.parseFloat(nextValue.toFixed(precision + 2)))
onChange(clamped)
onCommit?.(clamped)
}
setIsEditing(false)
}, [inputValue, onChange, onCommit, clamp, precision, value])
}, [inputValue, onChange, onCommit, clamp, precision, value, isLengthMeasurement, activeUnit, getDisplayStr])

const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
submitValue()
} else if (e.key === 'Escape') {
setInputValue(value.toFixed(precision))
setInputValue(getDisplayStr(value))
setIsEditing(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const adjustedStep = getAdjustedStep(step, e)
const adjustedStep = getAdjustedStep(step / multiplier, e)
const newV = clamp(
Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(adjustedStep))),
)
onChange(newV)
setInputValue(newV.toFixed(precision))
setInputValue(getDisplayStr(newV))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const adjustedStep = getAdjustedStep(step, e)
const adjustedStep = getAdjustedStep(step / multiplier, e)
const newV = clamp(
Number.parseFloat((value - adjustedStep).toFixed(stepPrecision(adjustedStep))),
)
onChange(newV)
setInputValue(newV.toFixed(precision))
setInputValue(getDisplayStr(newV))
}
},
[submitValue, value, precision, step, clamp, onChange],
[submitValue, value, getDisplayStr, step, multiplier, clamp, onChange],
)

return (
Expand Down Expand Up @@ -288,17 +312,19 @@ 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
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)}
{isLengthMeasurement && activeUnit === 'imperial'
? formatMeasurement(value, 'imperial', precision)
: Number(value.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>
Expand Down
3 changes: 2 additions & 1 deletion packages/editor/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action'
// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.).
export { markToolCancelConsumed } from './hooks/use-keyboard'
export { EDITOR_LAYER } from './lib/constants'
// Helper libs used by the kind-owned roof / stair / elevator panels.
export {
resolveCurrentBuildingId,
resolveElevatorNodeSupportY,
Expand Down Expand Up @@ -167,6 +166,8 @@ export {
getActivePaintMaterialLabel,
hasActivePaintMaterial,
} from './lib/material-paint'
// Helper libs used by the kind-owned roof / stair / elevator panels.
export { formatMeasurement, parseMeasurement } from './lib/measurements'
export { duplicateRoofSubtree } from './lib/roof-duplication'
export type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } from './lib/scene'
Expand Down
46 changes: 46 additions & 0 deletions packages/editor/src/lib/measurements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export function parseMeasurement(input: string, unit: 'metric' | 'imperial'): number | null {
if (unit === 'metric') {
const val = Number.parseFloat(input)
return Number.isNaN(val) ? null : val
}

// Handle imperial format: e.g., 10, 10', 10' 6", 6"
const feetInchesRegex = /^\s*(?:(\d+(?:\.\d+)?)\s*')?\s*(?:(\d+(?:\.\d+)?)\s*")?\s*$/
const match = input.match(feetInchesRegex)

if (match) {
if (!match[1] && !match[2]) {
// It's just a raw number like "10" or "5.5"
const val = Number.parseFloat(input)
if (Number.isNaN(val)) return null
return val / 3.28084
}

const feet = match[1] ? Number.parseFloat(match[1]) : 0
const inches = match[2] ? Number.parseFloat(match[2]) : 0

const totalFeet = feet + inches / 12
return totalFeet / 3.28084
}

return null
}

export function formatMeasurement(
valueInMeters: number,
unit: 'metric' | 'imperial',
precision = 2,
): string {
if (unit === 'metric') {
return Number.parseFloat(valueInMeters.toFixed(precision)).toFixed(precision)
}

const feet = valueInMeters * 3.28084
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)

if (inches === 12) return `${wholeFeet + 1}'0"`
if (wholeFeet === 0) return `${inches}"`
if (inches === 0) return `${wholeFeet}'`
return `${wholeFeet}'${inches}"`
}
Loading