refactor: clean up number formatting and step handling in property tabs

This commit is contained in:
Maze Winther 2026-03-27 21:24:59 +01:00
parent 3e75852ffe
commit c34e01c63e
8 changed files with 114 additions and 34 deletions

View File

@ -1,7 +1,11 @@
"use client"; "use client";
import type { ParamDefinition, NumberParamDefinition } from "@/lib/params"; 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 { SectionField } from "@/components/section";
import { NumberField } from "@/components/ui/number-field"; import { NumberField } from "@/components/ui/number-field";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
@ -156,7 +160,7 @@ function NumberParamField({
onPreview(clamped / displayMultiplier); onPreview(clamped / displayMultiplier);
}; };
const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step)); const maxFractionDigits = getFractionDigitsForStep({ step });
const draft = usePropertyDraft({ const draft = usePropertyDraft({
displayValue: formatNumberForDisplay({ displayValue: formatNumberForDisplay({

View File

@ -1,7 +1,13 @@
import { NumberField } from "@/components/ui/number-field"; import { NumberField } from "@/components/ui/number-field";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
import { DEFAULTS } from "@/lib/timeline/defaults"; 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 type { AudioElement, VideoElement } from "@/lib/timeline";
import { resolveNumberAtTime } from "@/lib/animation"; import { resolveNumberAtTime } from "@/lib/animation";
import { useElementPlayhead } from "../hooks/use-element-playhead"; import { useElementPlayhead } from "../hooks/use-element-playhead";
@ -18,6 +24,9 @@ import {
SectionTitle, SectionTitle,
} from "@/components/section"; } from "@/components/section";
const VOLUME_STEP = 0.1;
const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
export function AudioTab({ export function AudioTab({
element, element,
trackId, trackId,
@ -42,17 +51,24 @@ export function AudioTab({
propertyPath: "volume", propertyPath: "volume",
localTime, localTime,
isPlayheadWithinElementRange, isPlayheadWithinElementRange,
displayValue: resolvedVolume.toFixed(1), displayValue: formatNumberForDisplay({
value: resolvedVolume,
fractionDigits: VOLUME_FRACTION_DIGITS,
}),
parse: (input) => { parse: (input) => {
const parsed = parseFloat(input); const parsed = parseFloat(input);
if (Number.isNaN(parsed)) { if (Number.isNaN(parsed)) {
return null; 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, valueAtPlayhead: resolvedVolume,
step: 0.1, step: VOLUME_STEP,
buildBaseUpdates: ({ value }) => ({ buildBaseUpdates: ({ value }) => ({
volume: value, volume: value,
}), }),

View File

@ -38,7 +38,12 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { clamp, formatNumberForDisplay, snapToStep } from "@/utils/math"; import {
clamp,
formatNumberForDisplay,
getFractionDigitsForStep,
snapToStep,
} from "@/utils/math";
import { import {
Section, Section,
SectionContent, SectionContent,
@ -599,7 +604,7 @@ function MaskNumberField({
const max = isPercent ? 100 : param.max; const max = isPercent ? 100 : param.max;
const step = isPercent ? 1 : param.step; const step = isPercent ? 1 : param.step;
const displayValue = value * displayMultiplier; const displayValue = value * displayMultiplier;
const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step)); const maxFractionDigits = getFractionDigitsForStep({ step });
const clampDisplay = (nextDisplayValue: number) => const clampDisplay = (nextDisplayValue: number) =>
max !== undefined max !== undefined

View File

@ -22,16 +22,28 @@ import {
SectionTitle, SectionTitle,
} from "@/components/section"; } from "@/components/section";
import { usePropertyDraft } from "../hooks/use-property-draft"; 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 { 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 { function parseSpeedInput({ input }: { input: string }): number | null {
const parsed = parseFloat(input); const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null; if (Number.isNaN(parsed)) return null;
const rounded = Math.round(parsed * 100) / 100; return clampRetimeRate({
return clampRetimeRate({ rate: rounded }); rate: snapToStep({ value: parsed, step: SPEED_STEP }),
});
} }
function buildRetime({ function buildRetime({
@ -56,24 +68,21 @@ export function SpeedTab({
const rate = clampRetimeRate({ const rate = clampRetimeRate({
rate: element.retime?.rate ?? DEFAULT_RETIME_RATE, rate: element.retime?.rate ?? DEFAULT_RETIME_RATE,
}); });
const maintainPitchAvailable = canMaintainPitch({ rate }); const isPitchPreserveAvailable = canMaintainPitch({ rate });
const maintainPitch = element.retime?.maintainPitch ?? false; const maintainPitch = element.retime?.maintainPitch ?? false;
const pendingRateRef = useRef(rate); const pendingRateRef = useRef(rate);
const applyRetime = ({ const commitRetime = ({
rate: nextRate, rate: nextRate,
maintainPitch: nextMaintainPitch, maintainPitch: nextMaintainPitch,
pushHistory = true,
}: { }: {
rate: number; rate: number;
maintainPitch: boolean; maintainPitch: boolean;
pushHistory?: boolean;
}) => { }) => {
editor.timeline.updateElementRetime({ editor.timeline.updateElementRetime({
trackId, trackId,
elementId: element.id, elementId: element.id,
retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }), retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }),
pushHistory,
}); });
}; };
@ -82,10 +91,20 @@ export function SpeedTab({
parse: (input) => parseSpeedInput({ input }), parse: (input) => parseSpeedInput({ input }),
onPreview: (nextRate) => { onPreview: (nextRate) => {
pendingRateRef.current = 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: () => { onCommit: () => {
applyRetime({ rate: pendingRateRef.current, maintainPitch }); commitRetime({ rate: pendingRateRef.current, maintainPitch });
}, },
}); });
@ -115,20 +134,20 @@ export function SpeedTab({
onScrub={speedDraft.scrubTo} onScrub={speedDraft.scrubTo}
onScrubEnd={speedDraft.commitScrub} onScrubEnd={speedDraft.commitScrub}
onReset={() => onReset={() =>
applyRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch }) commitRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch })
} }
isDefault={rate === DEFAULT_RETIME_RATE} isDefault={rate === DEFAULT_RETIME_RATE}
/> />
</SectionField> </SectionField>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm">Change pitch</span> <span className="text-sm">Change pitch</span>
<Switch <Switch
checked={!maintainPitch} checked={!maintainPitch}
disabled={!maintainPitchAvailable} disabled={!isPitchPreserveAvailable}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
applyRetime({ rate, maintainPitch: !checked }) commitRetime({ rate, maintainPitch: !checked })
} }
/> />
</div> </div>
</SectionFields> </SectionFields>
</SectionContent> </SectionContent>

View File

@ -16,7 +16,7 @@ import {
import { ColorPicker } from "@/components/ui/color-picker"; import { ColorPicker } from "@/components/ui/color-picker";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { uppercase } from "@/utils/string"; import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math"; import { clamp, formatNumberForDisplay } from "@/utils/math";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_COLOR } from "@/constants/project-constants"; import { DEFAULT_COLOR } from "@/constants/project-constants";
import { import {
@ -251,7 +251,10 @@ function SpacingSection({
}); });
const lineHeight = usePropertyDraft({ const lineHeight = usePropertyDraft({
displayValue: (element.lineHeight ?? DEFAULTS.text.lineHeight).toFixed(1), displayValue: formatNumberForDisplay({
value: element.lineHeight ?? DEFAULTS.text.lineHeight,
fractionDigits: 1,
}),
parse: (input) => { parse: (input) => {
const parsed = parseFloat(input); const parsed = parseFloat(input);
return Number.isNaN(parsed) return Number.isNaN(parsed)

View File

@ -27,7 +27,7 @@ import { ColorPicker } from "@/components/ui/color-picker";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { uppercase } from "@/utils/string"; import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math"; import { clamp, formatNumberForDisplay } from "@/utils/math";
import { import {
timelineTimeToPixels, timelineTimeToPixels,
timelineTimeToSnappedPixels, timelineTimeToSnappedPixels,
@ -188,7 +188,7 @@ function TimelineBookmark({
left: `${bookmarkLeft}px`, left: `${bookmarkLeft}px`,
width: `${bookmarkWidth}px`, width: `${bookmarkWidth}px`,
}} }}
aria-label={`Bookmark at ${time.toFixed(1)}s`} aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`}
type="button" type="button"
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onClick={handleClick} onClick={handleClick}

View File

@ -1,3 +1,5 @@
import { formatNumberForDisplay } from "@/utils/math";
const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const; const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
export const STORAGE_HEADROOM_RESERVE_BYTES = 50 * 1024 * 1024; 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; 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<StorageQuotaStatus> { export async function readStorageQuotaStatus(): Promise<StorageQuotaStatus> {

View File

@ -22,7 +22,7 @@ export function clampRound({
return Math.round(clamp({ value, min, max })); 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(); const normalizedStep = step.toString().toLowerCase();
if (normalizedStep.includes("e-")) { if (normalizedStep.includes("e-")) {
return Number(normalizedStep.split("e-")[1] ?? 0); return Number(normalizedStep.split("e-")[1] ?? 0);
@ -59,12 +59,43 @@ export function isNearlyEqual({
export function formatNumberForDisplay({ export function formatNumberForDisplay({
value, value,
fractionDigits,
minFractionDigits = 0,
maxFractionDigits = 6, maxFractionDigits = 6,
}: { }: {
value: number; value: number;
fractionDigits?: number;
minFractionDigits?: number;
maxFractionDigits?: number; maxFractionDigits?: number;
}): string { }): 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({ export function evaluateMathExpression({