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 8e936a7594
commit b949fda7bc
8 changed files with 114 additions and 34 deletions

View File

@ -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({

View File

@ -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,
}),

View File

@ -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

View File

@ -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}
/>
</SectionField>
<div className="flex items-center justify-between">
<span className="text-sm">Change pitch</span>
<Switch
checked={!maintainPitch}
disabled={!maintainPitchAvailable}
onCheckedChange={(checked) =>
applyRetime({ rate, maintainPitch: !checked })
}
/>
<span className="text-sm">Change pitch</span>
<Switch
checked={!maintainPitch}
disabled={!isPitchPreserveAvailable}
onCheckedChange={(checked) =>
commitRetime({ rate, maintainPitch: !checked })
}
/>
</div>
</SectionFields>
</SectionContent>

View File

@ -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)

View File

@ -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}

View File

@ -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<StorageQuotaStatus> {

View File

@ -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({