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