diff --git a/packages/core/src/i18n/i18n.test.tsx b/packages/core/src/i18n/i18n.test.tsx index 0a57faf24e..b2cad66916 100644 --- a/packages/core/src/i18n/i18n.test.tsx +++ b/packages/core/src/i18n/i18n.test.tsx @@ -6,6 +6,7 @@ import { defaultI18n, formatLength, formatNumber, + getLocale, LevelNode, levelDisplayNumber, PascalI18nProvider, @@ -134,19 +135,29 @@ describe('message registry', () => { expect(localeChanges).toBe(1) }) - test('keeps a scoped locale across catalog refreshes and accepts repeated global locale intent', async () => { + test('keeps a request-scoped locale across compiled catalog refreshes', () => { + setLocale('en') const scope = createI18n({ locale: 'fr' }) const release = scope.retain() + const localeChanges: string[] = [] const unsubscribe = defaultI18n.subscribeLocale((locale) => scope.setLocale(locale)) + const stopRecording = defaultI18n.subscribeLocale((locale) => localeChanges.push(locale)) + + registerCompiledMessages('fr', 'viewer', { + 'test.catalog-refresh': ['Actualisé'], + }) - await registerMessages('fr', 'viewer', { 'test.catalog-refresh': 'Actualisé' }) + expect(defaultI18n.getLocale()).toBe('en') expect(scope.getLocale()).toBe('fr') expect(scope._({ id: 'viewer.test.catalog-refresh', message: 'Refreshed' })).toBe('Actualisé') + expect(localeChanges).toEqual([]) - setLocale('en') - expect(scope.getLocale()).toBe('en') + setLocale('ja') + expect(scope.getLocale()).toBe('ja') + expect(localeChanges).toEqual(['ja']) unsubscribe() + stopRecording() release() scope.destroy() }) @@ -253,6 +264,7 @@ describe('locale formatting', () => { }) test('server rendering uses the selected locale for text and numbers', async () => { + setLocale('en') await registerMessages('fr', 'viewer', { greeting: 'Bonjour' }) const scope = createI18n({ locale: 'fr' }) @@ -273,6 +285,8 @@ test('server rendering uses the selected locale for text and numbers', async () , ) + expect(getLocale()).toBe('en') + expect(formatNumber(2.75, { max: 2 })).toBe('2.75') expect(serverHtml).toContain('Bonjour') expect(serverHtml).toContain('2,75') expect(serverHtml).not.toContain('Hello') diff --git a/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts b/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts index 7b05abcff8..6908659c9d 100644 --- a/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +++ b/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts @@ -1,10 +1,16 @@ import { describe, expect, test } from 'bun:test' import { + formatMeasurementInputValue, isProjectedFloorplanAxisPointVerified, resolveFloorplanMeasurementAxisSnap, resolveProjectedFloorplanSnap, } from './floorplan-measurement-tool-layer' +test('extrusion inputs write values back with the scoped decimal separator', () => { + expect(formatMeasurementInputValue(2.75, 'fr')).toBe('2,75') + expect(formatMeasurementInputValue(2.75, 'en')).toBe('2.75') +}) + describe('resolveFloorplanMeasurementAxisSnap', () => { test('snaps to the closest X or Z axis inside the screen-space threshold', () => { expect(resolveFloorplanMeasurementAxisSnap([3, 0, 2], [1, 0, 1], 4, 9)).toEqual({ diff --git a/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx index e24fc9fca4..6b069a5ace 100644 --- a/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx @@ -6,6 +6,7 @@ import { closestMeasurementFeatureBinding, collectAlignmentAnchors, emitter, + formatNumber, type GeometryContext, type MeasurementFeatureAnchor, type MeasurementSnapKind, @@ -78,6 +79,10 @@ export type ProjectedFloorplanSnap = { const MAX_REGISTERED_SNAP_ELEMENTS = 512 const MAX_REGISTERED_SNAP_SEGMENTS = 4096 const MAX_SAMPLED_PATH_SEGMENTS = 48 + +export function formatMeasurementInputValue(value: number, locale: string): string { + return formatNumber(value, { locale, max: 3 }) +} const VERTEX_SNAP_DISTANCE_PX = 16 const EDGE_SNAP_DISTANCE_PX = 10 const SEMANTIC_FEATURE_SNAP_DISTANCE = 0.2 @@ -642,13 +647,17 @@ function FloorplanExtrusionControl({ const points = useMeasurementDraft((state) => state.points) const baseNormal = useMeasurementDraft((state) => state.baseNormal) const [value, setValue] = useState(() => - extrusionHeight === 0 ? '' : String(metersToLinearUnit(extrusionHeight, unit)), + extrusionHeight === 0 + ? '' + : formatMeasurementInputValue(metersToLinearUnit(extrusionHeight, unit), locale), ) useEffect(() => { const height = useMeasurementDraft.getState().extrusionHeight - setValue(height === 0 ? '' : String(Number(metersToLinearUnit(height, unit).toFixed(3)))) - }, [unit]) + setValue( + height === 0 ? '' : formatMeasurementInputValue(metersToLinearUnit(height, unit), locale), + ) + }, [locale, unit]) const width = 260 * unitsPerPixel const height = 72 * unitsPerPixel diff --git a/packages/editor/src/components/editor/floorplan-panel.test.ts b/packages/editor/src/components/editor/floorplan-panel.test.ts index 4b24209cc1..dc3be36eb6 100644 --- a/packages/editor/src/components/editor/floorplan-panel.test.ts +++ b/packages/editor/src/components/editor/floorplan-panel.test.ts @@ -1,7 +1,12 @@ import { expect, test } from 'bun:test' -import { parseReferenceScaleLength } from './floorplan-panel' +import { formatFloorplanNumber, parseReferenceScaleLength } from './floorplan-panel' test('floor-plan reference scales parse with the scoped locale', () => { expect(parseReferenceScaleLength('1,000', 'meters', 'fr')).toBe(1) expect(parseReferenceScaleLength('1,000', 'meters', 'en')).toBe(1000) }) + +test('floor-plan reference scales display with the scoped locale', () => { + expect(formatFloorplanNumber(2.75, 'fr')).toBe('2,75') + expect(formatFloorplanNumber(2.75, 'en')).toBe('2.75') +}) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index b257b63f69..a28adcb285 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -18,6 +18,7 @@ import { emitter, type FenceNode, type FloorplanGeometry, + formatNumber as formatLocalizedNumber, type GeometryContext, type GuideNode, getRenderableSlabPolygon, @@ -2439,8 +2440,8 @@ function formatMeasurement( return formatLinearMeasurement(measuredValue, unit, metricNotation) } -function formatNumber(value: number, fractionDigits = 2) { - return Number.parseFloat(value.toFixed(fractionDigits)).toString() +export function formatFloorplanNumber(value: number, locale: string, fractionDigits = 2) { + return formatLocalizedNumber(value, { locale, max: fractionDigits }) } function convertReferenceLengthToMeters(value: number, unit: ReferenceScaleUnit) { @@ -2515,8 +2516,8 @@ function getReferenceScaleUnitLabel(unit: ReferenceScaleUnit) { } } -function formatReferenceScaleLabel(value: number, unit: ReferenceScaleUnit) { - return `${formatNumber(value)} ${getReferenceScaleUnitLabel(unit)}` +function formatReferenceScaleLabel(value: number, unit: ReferenceScaleUnit, locale: string) { + return `${formatFloorplanNumber(value, locale)} ${getReferenceScaleUnitLabel(unit)}` } function getPolygonAreaAndCentroid(polygon: Point2D[]) { @@ -7220,7 +7221,7 @@ export function FloorplanPanel({ realLengthMeters, measuredLengthUnits: scaledMeasuredLengthUnits, metersPerUnit, - label: formatReferenceScaleLabel(displayLength, referenceScaleUnit), + label: formatReferenceScaleLabel(displayLength, referenceScaleUnit, locale), }, } as Partial, ) @@ -10037,10 +10038,11 @@ export function FloorplanPanel({ // confirming without editing is a no-op instead of a surprise // rescale (plan units are meters; convert when defaulting to feet). setReferenceScaleValue( - formatNumber( + formatFloorplanNumber( unit === 'imperial' ? measuredLengthUnits / linearUnitToMeters(1, 'imperial') : measuredLengthUnits, + locale, 2, ), ) @@ -11399,7 +11401,7 @@ export function FloorplanPanel({
{pendingReferenceImageScaleFactor - ? `Image will scale ${formatNumber(pendingReferenceImageScaleFactor, 3)}x from the first point.` + ? `Image will scale ${formatFloorplanNumber(pendingReferenceImageScaleFactor, locale, 3)}x from the first point.` : 'Enter a length greater than 0.'}
diff --git a/packages/editor/src/components/ui/controls/metric-control.test.ts b/packages/editor/src/components/ui/controls/metric-control.test.ts index 14ee72eee2..b565f096e7 100644 --- a/packages/editor/src/components/ui/controls/metric-control.test.ts +++ b/packages/editor/src/components/ui/controls/metric-control.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test' -import { parseMetricInput } from './metric-control' +import { formatMetricInputValue, parseMetricInput } from './metric-control' test('metric controls parse stair dimensions with the scoped locale', () => { expect( @@ -17,3 +17,8 @@ test('metric controls parse stair dimensions with the scoped locale', () => { }), ).toBe(1000) }) + +test('metric controls write values back with the scoped decimal separator', () => { + expect(formatMetricInputValue(2.75, 2, 'fr')).toBe('2,75') + expect(formatMetricInputValue(2.75, 2, 'en')).toBe('2.75') +}) diff --git a/packages/editor/src/components/ui/controls/metric-control.tsx b/packages/editor/src/components/ui/controls/metric-control.tsx index b810e96560..cee440b1a9 100644 --- a/packages/editor/src/components/ui/controls/metric-control.tsx +++ b/packages/editor/src/components/ui/controls/metric-control.tsx @@ -1,6 +1,6 @@ 'use client' -import { parseLocalizedNumber, useLocale, useScene } from '@pascal-app/core' +import { formatNumber, parseLocalizedNumber, useLocale, 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' @@ -35,6 +35,19 @@ export function parseMetricInput( : parseLocalizedNumber(text, options.locale) } +export function formatMetricInputValue( + value: number, + precision: number, + locale: string, +): string { + return formatNumber(value, { locale, min: precision, max: precision }) +} + +function roundToPrecision(value: number, precision: number): number { + const factor = 10 ** precision + return Math.round((value + Number.EPSILON) * factor) / factor +} + export function MetricControl({ label, value, @@ -67,16 +80,20 @@ export function MetricControl({ ) const roundStoredValueForDisplayPrecision = useCallback( (storedValue: number) => - clamp(toStoredValue(Number.parseFloat(toDisplayValue(storedValue).toFixed(precision)))), + clamp(toStoredValue(roundToPrecision(toDisplayValue(storedValue), precision))), [clamp, precision, toDisplayValue, toStoredValue], ) const displayValue = toDisplayValue(value) + const formatDisplayValue = useCallback( + (nextValue: number) => formatMetricInputValue(nextValue, precision, locale), + [locale, precision], + ) const [isEditing, setIsEditing] = useState(false) const [isDragging, setIsDragging] = useState(false) const [isHovered, setIsHovered] = useState(false) - const [inputValue, setInputValue] = useState(displayValue.toFixed(precision)) + const [inputValue, setInputValue] = useState(() => formatDisplayValue(displayValue)) const startXRef = useRef(0) const startValueRef = useRef(0) const containerRef = useRef(null) @@ -97,9 +114,9 @@ export function MetricControl({ useEffect(() => { if (!isEditing) { - setInputValue(displayValue.toFixed(precision)) + setInputValue(formatDisplayValue(displayValue)) } - }, [displayValue, precision, isEditing]) + }, [displayValue, formatDisplayValue, isEditing]) useEffect(() => { const container = containerRef.current @@ -237,8 +254,8 @@ export function MetricControl({ const handleValueClick = useCallback(() => { setIsEditing(true) - setInputValue(toDisplayValue(value).toFixed(precision)) - }, [value, toDisplayValue, precision]) + setInputValue(formatDisplayValue(toDisplayValue(value))) + }, [formatDisplayValue, value, toDisplayValue]) const handleInputChange = useCallback((e: React.ChangeEvent) => { setInputValue(e.target.value) @@ -249,7 +266,7 @@ export function MetricControl({ const parsed = parseMetricInput(inputValue, unit, { isImperial, locale, parseUnit }) const stored = parsed === null ? null : spec ? parsed : toStoredValue(parsed) if (stored === null) { - setInputValue(toDisplayValue(value).toFixed(precision)) + setInputValue(formatDisplayValue(toDisplayValue(value))) } else { applyCommittedValue(clamp(stored)) } @@ -264,7 +281,7 @@ export function MetricControl({ clamp, toStoredValue, value, - precision, + formatDisplayValue, toDisplayValue, ]) @@ -290,25 +307,25 @@ export function MetricControl({ if (e.key === 'Enter') { submitValue() } else if (e.key === 'Escape') { - setInputValue(toDisplayValue(value).toFixed(precision)) + setInputValue(formatDisplayValue(toDisplayValue(value))) setIsEditing(false) } else if (e.key === 'ArrowUp') { e.preventDefault() const newV = clamp(value + toStoredValue(step)) applyCommittedValue(newV) - setInputValue(toDisplayValue(newV).toFixed(precision)) + setInputValue(formatDisplayValue(toDisplayValue(newV))) } else if (e.key === 'ArrowDown') { e.preventDefault() const newV = clamp(value - toStoredValue(step)) applyCommittedValue(newV) - setInputValue(toDisplayValue(newV).toFixed(precision)) + setInputValue(formatDisplayValue(toDisplayValue(newV))) } }, [ submitValue, value, toDisplayValue, - precision, + formatDisplayValue, step, clamp, applyCommittedValue, @@ -373,7 +390,7 @@ export function MetricControl({ onClick={handleValueClick} > - {Number(displayValue.toFixed(precision)).toFixed(precision)} + {formatDisplayValue(displayValue)} {displayUnit && {displayUnit}} diff --git a/packages/editor/src/components/ui/controls/slider-control.test.tsx b/packages/editor/src/components/ui/controls/slider-control.test.tsx index bfb964d811..a00fc8e5ca 100644 --- a/packages/editor/src/components/ui/controls/slider-control.test.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.test.tsx @@ -1,9 +1,10 @@ import { expect, test } from 'bun:test' -import { createI18n, PascalI18nProvider } from '@pascal-app/core' +import { createI18n, getLocale, PascalI18nProvider, setLocale } from '@pascal-app/core' import { renderToString } from 'react-dom/server' import { parseSliderBareValue, SliderControl } from './slider-control' -test('slider numbers use the scoped locale for display and parsing', () => { +test('slider numbers use the scoped locale for display and parsing while the global locale is English', () => { + setLocale('en') const i18n = createI18n({ locale: 'fr' }) try { const html = renderToString( @@ -12,6 +13,7 @@ test('slider numbers use the scoped locale for display and parsing', () => { , ) + expect(getLocale()).toBe('en') expect(html).toContain('2,75') expect(parseSliderBareValue('2,75', i18n.getLocale())).toBe(2.75) expect(parseSliderBareValue('1e2', i18n.getLocale())).toBe(100)