From b949fda7bc28516a40457e4d16e6ecf3cbdfb92d Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Mar 2026 21:24:59 +0100 Subject: [PATCH] refactor: clean up number formatting and step handling in property tabs --- .../components/property-param-field.tsx | 8 ++- .../panels/properties/tabs/audio-tab.tsx | 24 ++++++-- .../panels/properties/tabs/masks-tab.tsx | 9 ++- .../panels/properties/tabs/speed-tab.tsx | 57 ++++++++++++------- .../panels/properties/tabs/text-tab.tsx | 7 ++- .../editor/panels/timeline/bookmarks.tsx | 4 +- apps/web/src/services/storage/quota.ts | 4 +- apps/web/src/utils/math.ts | 35 +++++++++++- 8 files changed, 114 insertions(+), 34 deletions(-) diff --git a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx index e69c6918..9cc2b02e 100644 --- a/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx +++ b/apps/web/src/components/editor/panels/properties/components/property-param-field.tsx @@ -1,7 +1,11 @@ "use client"; import type { ParamDefinition, NumberParamDefinition } from "@/lib/params"; -import { formatNumberForDisplay, snapToStep } from "@/utils/math"; +import { + formatNumberForDisplay, + getFractionDigitsForStep, + snapToStep, +} from "@/utils/math"; import { SectionField } from "@/components/section"; import { NumberField } from "@/components/ui/number-field"; import { Switch } from "@/components/ui/switch"; @@ -156,7 +160,7 @@ function NumberParamField({ onPreview(clamped / displayMultiplier); }; - const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step)); + const maxFractionDigits = getFractionDigitsForStep({ step }); const draft = usePropertyDraft({ displayValue: formatNumberForDisplay({ diff --git a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx index a92f7be2..4dbc1a17 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx @@ -1,7 +1,13 @@ import { NumberField } from "@/components/ui/number-field"; import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; import { DEFAULTS } from "@/lib/timeline/defaults"; -import { clamp, isNearlyEqual } from "@/utils/math"; +import { + clamp, + formatNumberForDisplay, + getFractionDigitsForStep, + isNearlyEqual, + snapToStep, +} from "@/utils/math"; import type { AudioElement, VideoElement } from "@/lib/timeline"; import { resolveNumberAtTime } from "@/lib/animation"; import { useElementPlayhead } from "../hooks/use-element-playhead"; @@ -18,6 +24,9 @@ import { SectionTitle, } from "@/components/section"; +const VOLUME_STEP = 0.1; +const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP }); + export function AudioTab({ element, trackId, @@ -42,17 +51,24 @@ export function AudioTab({ propertyPath: "volume", localTime, isPlayheadWithinElementRange, - displayValue: resolvedVolume.toFixed(1), + displayValue: formatNumberForDisplay({ + value: resolvedVolume, + fractionDigits: VOLUME_FRACTION_DIGITS, + }), parse: (input) => { const parsed = parseFloat(input); if (Number.isNaN(parsed)) { return null; } - return clamp({ value: parsed, min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }); + return clamp({ + value: snapToStep({ value: parsed, step: VOLUME_STEP }), + min: VOLUME_DB_MIN, + max: VOLUME_DB_MAX, + }); }, valueAtPlayhead: resolvedVolume, - step: 0.1, + step: VOLUME_STEP, buildBaseUpdates: ({ value }) => ({ volume: value, }), diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx index 6a5d0d0b..db8bd728 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx @@ -38,7 +38,12 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { clamp, formatNumberForDisplay, snapToStep } from "@/utils/math"; +import { + clamp, + formatNumberForDisplay, + getFractionDigitsForStep, + snapToStep, +} from "@/utils/math"; import { Section, SectionContent, @@ -599,7 +604,7 @@ function MaskNumberField({ const max = isPercent ? 100 : param.max; const step = isPercent ? 1 : param.step; const displayValue = value * displayMultiplier; - const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step)); + const maxFractionDigits = getFractionDigitsForStep({ step }); const clampDisplay = (nextDisplayValue: number) => max !== undefined diff --git a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx index 4434890e..da86c626 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx @@ -22,16 +22,28 @@ import { SectionTitle, } from "@/components/section"; import { usePropertyDraft } from "../hooks/use-property-draft"; +import { + formatNumberForDisplay, + getFractionDigitsForStep, + snapToStep, +} from "@/utils/math"; + +const SPEED_STEP = 0.01; +const SPEED_FRACTION_DIGITS = getFractionDigitsForStep({ step: SPEED_STEP }); function rateToDisplay({ rate }: { rate: number }): string { - return rate.toFixed(2); + return formatNumberForDisplay({ + value: rate, + fractionDigits: SPEED_FRACTION_DIGITS, + }); } function parseSpeedInput({ input }: { input: string }): number | null { const parsed = parseFloat(input); if (Number.isNaN(parsed)) return null; - const rounded = Math.round(parsed * 100) / 100; - return clampRetimeRate({ rate: rounded }); + return clampRetimeRate({ + rate: snapToStep({ value: parsed, step: SPEED_STEP }), + }); } function buildRetime({ @@ -56,24 +68,21 @@ export function SpeedTab({ const rate = clampRetimeRate({ rate: element.retime?.rate ?? DEFAULT_RETIME_RATE, }); - const maintainPitchAvailable = canMaintainPitch({ rate }); + const isPitchPreserveAvailable = canMaintainPitch({ rate }); const maintainPitch = element.retime?.maintainPitch ?? false; const pendingRateRef = useRef(rate); - const applyRetime = ({ + const commitRetime = ({ rate: nextRate, maintainPitch: nextMaintainPitch, - pushHistory = true, }: { rate: number; maintainPitch: boolean; - pushHistory?: boolean; }) => { editor.timeline.updateElementRetime({ trackId, elementId: element.id, retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }), - pushHistory, }); }; @@ -82,10 +91,20 @@ export function SpeedTab({ parse: (input) => parseSpeedInput({ input }), onPreview: (nextRate) => { pendingRateRef.current = nextRate; - applyRetime({ rate: nextRate, maintainPitch, pushHistory: false }); + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + retime: buildRetime({ rate: nextRate, maintainPitch }), + }, + }, + ], + }); }, onCommit: () => { - applyRetime({ rate: pendingRateRef.current, maintainPitch }); + commitRetime({ rate: pendingRateRef.current, maintainPitch }); }, }); @@ -115,20 +134,20 @@ export function SpeedTab({ onScrub={speedDraft.scrubTo} onScrubEnd={speedDraft.commitScrub} onReset={() => - applyRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch }) + commitRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch }) } isDefault={rate === DEFAULT_RETIME_RATE} />
- Change pitch - - applyRetime({ rate, maintainPitch: !checked }) - } - /> + Change pitch + + commitRetime({ rate, maintainPitch: !checked }) + } + />
diff --git a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx index 45831dc0..be85bede 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx @@ -16,7 +16,7 @@ import { import { ColorPicker } from "@/components/ui/color-picker"; import { Button } from "@/components/ui/button"; import { uppercase } from "@/utils/string"; -import { clamp } from "@/utils/math"; +import { clamp, formatNumberForDisplay } from "@/utils/math"; import { useEditor } from "@/hooks/use-editor"; import { DEFAULT_COLOR } from "@/constants/project-constants"; import { @@ -251,7 +251,10 @@ function SpacingSection({ }); const lineHeight = usePropertyDraft({ - displayValue: (element.lineHeight ?? DEFAULTS.text.lineHeight).toFixed(1), + displayValue: formatNumberForDisplay({ + value: element.lineHeight ?? DEFAULTS.text.lineHeight, + fractionDigits: 1, + }), parse: (input) => { const parsed = parseFloat(input); return Number.isNaN(parsed) diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx index 7000f98b..b47f9d46 100644 --- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx +++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx @@ -27,7 +27,7 @@ import { ColorPicker } from "@/components/ui/color-picker"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { uppercase } from "@/utils/string"; -import { clamp } from "@/utils/math"; +import { clamp, formatNumberForDisplay } from "@/utils/math"; import { timelineTimeToPixels, timelineTimeToSnappedPixels, @@ -188,7 +188,7 @@ function TimelineBookmark({ left: `${bookmarkLeft}px`, width: `${bookmarkWidth}px`, }} - aria-label={`Bookmark at ${time.toFixed(1)}s`} + aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`} type="button" onMouseDown={handleMouseDown} onClick={handleClick} diff --git a/apps/web/src/services/storage/quota.ts b/apps/web/src/services/storage/quota.ts index 4d1adad5..2bdf303a 100644 --- a/apps/web/src/services/storage/quota.ts +++ b/apps/web/src/services/storage/quota.ts @@ -1,3 +1,5 @@ +import { formatNumberForDisplay } from "@/utils/math"; + const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const; export const STORAGE_HEADROOM_RESERVE_BYTES = 50 * 1024 * 1024; @@ -37,7 +39,7 @@ export function formatStorageBytes({ bytes }: { bytes: number }): string { } const precision = value >= 10 || unitIndex === 0 ? 0 : 1; - return `${value.toFixed(precision)} ${BYTE_UNITS[unitIndex]}`; + return `${formatNumberForDisplay({ value, fractionDigits: precision })} ${BYTE_UNITS[unitIndex]}`; } export async function readStorageQuotaStatus(): Promise { diff --git a/apps/web/src/utils/math.ts b/apps/web/src/utils/math.ts index 6e912b43..8043871f 100644 --- a/apps/web/src/utils/math.ts +++ b/apps/web/src/utils/math.ts @@ -22,7 +22,7 @@ export function clampRound({ return Math.round(clamp({ value, min, max })); } -function getFractionDigitsForStep({ step }: { step: number }): number { +export function getFractionDigitsForStep({ step }: { step: number }): number { const normalizedStep = step.toString().toLowerCase(); if (normalizedStep.includes("e-")) { return Number(normalizedStep.split("e-")[1] ?? 0); @@ -59,12 +59,43 @@ export function isNearlyEqual({ export function formatNumberForDisplay({ value, + fractionDigits, + minFractionDigits = 0, maxFractionDigits = 6, }: { value: number; + fractionDigits?: number; + minFractionDigits?: number; maxFractionDigits?: number; }): string { - return Number(value.toFixed(maxFractionDigits)).toString(); + const resolvedMaxFractionDigits = Math.max( + 0, + fractionDigits ?? maxFractionDigits, + ); + const resolvedMinFractionDigits = Math.min( + Math.max(0, fractionDigits ?? minFractionDigits), + resolvedMaxFractionDigits, + ); + const fixedValue = value.toFixed(resolvedMaxFractionDigits); + + if (resolvedMaxFractionDigits === 0) { + return Number(fixedValue) === 0 ? "0" : fixedValue; + } + + const [integerPart, fractionPart = ""] = fixedValue.split("."); + const normalizedIntegerPart = Number(fixedValue) === 0 ? "0" : integerPart; + let trimmedFractionPart = fractionPart; + + while ( + trimmedFractionPart.length > resolvedMinFractionDigits && + trimmedFractionPart.endsWith("0") + ) { + trimmedFractionPart = trimmedFractionPart.slice(0, -1); + } + + return trimmedFractionPart + ? `${normalizedIntegerPart}.${trimmedFractionPart}` + : normalizedIntegerPart; } export function evaluateMathExpression({