refactor: clean up number formatting and step handling in property tabs
This commit is contained in:
parent
8e936a7594
commit
b949fda7bc
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue