diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index b0b46dfd3..e466e9749 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -53,6 +53,11 @@ import ColorPicker from "../ui/color-picker"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { BlurSettingsPanel } from "./BlurSettingsPanel"; import { CropControl } from "./CropControl"; +import { + formatCustomPlaybackSpeedDraft, + parseCustomPlaybackSpeedDraft, + sanitizeCustomPlaybackSpeedDraft, +} from "./customPlaybackSpeed"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; import type { AnnotationRegion, @@ -90,46 +95,57 @@ function CustomSpeedInput({ onError: () => void; }) { const isPreset = SPEED_OPTIONS.some((o) => o.speed === value); - const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value))); + const [draft, setDraft] = useState(formatCustomPlaybackSpeedDraft(value, isPreset)); const [isFocused, setIsFocused] = useState(false); const prevValue = useRef(value); if (!isFocused && prevValue.current !== value) { prevValue.current = value; - setDraft(isPreset ? "" : String(Math.round(value))); + setDraft(formatCustomPlaybackSpeedDraft(value, isPreset)); } const handleChange = useCallback( (e: React.ChangeEvent) => { - const digits = e.target.value.replace(/\D/g, ""); - if (digits === "") { + const nextDraft = sanitizeCustomPlaybackSpeedDraft(e.target.value); + if (nextDraft === "") { setDraft(""); return; } - const num = Number(digits); - if (num > MAX_PLAYBACK_SPEED) { + + const num = parseCustomPlaybackSpeedDraft(nextDraft); + if (num === null && Number(nextDraft) > MAX_PLAYBACK_SPEED) { onError(); return; } - setDraft(digits); - if (num >= 1) onChange(num); + + setDraft(nextDraft); + if (num !== null) onChange(num); }, [onChange, onError], ); const handleBlur = useCallback(() => { setIsFocused(false); - if (!draft || Number(draft) < 1) { - setDraft(isPreset ? "" : String(Math.round(value))); + const parsed = parseCustomPlaybackSpeedDraft(draft); + if (parsed === null) { + setDraft(formatCustomPlaybackSpeedDraft(value, isPreset)); + return; } + + setDraft( + formatCustomPlaybackSpeedDraft( + parsed, + SPEED_OPTIONS.some((option) => option.speed === parsed), + ), + ); }, [draft, isPreset, value]); return (
setIsFocused(true)} diff --git a/src/components/video-editor/customPlaybackSpeed.test.ts b/src/components/video-editor/customPlaybackSpeed.test.ts new file mode 100644 index 000000000..6c8ab8990 --- /dev/null +++ b/src/components/video-editor/customPlaybackSpeed.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest"; +import { + formatCustomPlaybackSpeedDraft, + parseCustomPlaybackSpeedDraft, + sanitizeCustomPlaybackSpeedDraft, +} from "./customPlaybackSpeed"; + +describe("custom playback speed helpers", () => { + test("formats non-preset values without rounding them to whole numbers", () => { + expect(formatCustomPlaybackSpeedDraft(1.1, false)).toBe("1.1"); + }); + + test("formats preset values as an empty draft", () => { + expect(formatCustomPlaybackSpeedDraft(1.5, true)).toBe(""); + }); + + test("preserves decimal input and limits precision to two places", () => { + expect(sanitizeCustomPlaybackSpeedDraft("1.1")).toBe("1.1"); + expect(sanitizeCustomPlaybackSpeedDraft("1..234")).toBe("1.23"); + }); + + test("normalizes leading decimal input", () => { + expect(sanitizeCustomPlaybackSpeedDraft(".9")).toBe("0.9"); + }); + + test("parses valid decimal speeds within the supported range", () => { + expect(parseCustomPlaybackSpeedDraft("0.9")).toBe(0.9); + expect(parseCustomPlaybackSpeedDraft("1.1")).toBe(1.1); + }); + + test("rejects values outside the supported range", () => { + expect(parseCustomPlaybackSpeedDraft("0.05")).toBeNull(); + expect(parseCustomPlaybackSpeedDraft("16.01")).toBeNull(); + }); +}); diff --git a/src/components/video-editor/customPlaybackSpeed.ts b/src/components/video-editor/customPlaybackSpeed.ts new file mode 100644 index 000000000..909c923b6 --- /dev/null +++ b/src/components/video-editor/customPlaybackSpeed.ts @@ -0,0 +1,31 @@ +import { clampPlaybackSpeed, MAX_PLAYBACK_SPEED, MIN_PLAYBACK_SPEED } from "./types"; + +export function formatCustomPlaybackSpeedDraft(value: number, isPreset: boolean): string { + return isPreset ? "" : String(clampPlaybackSpeed(value)); +} + +export function sanitizeCustomPlaybackSpeedDraft(input: string): string { + const cleaned = input.replace(/[^0-9.]/g, ""); + if (!cleaned) return ""; + + const startsWithDot = cleaned.startsWith("."); + const [integerPartRaw, ...decimalParts] = cleaned.split("."); + const integerPart = integerPartRaw.replace(/^0+(?=\d)/, "") || "0"; + const decimalPart = decimalParts.join("").slice(0, 2); + + if (startsWithDot || cleaned.includes(".")) { + return `${integerPart}.${decimalPart}`; + } + + return integerPart; +} + +export function parseCustomPlaybackSpeedDraft(draft: string): number | null { + if (!draft || draft.endsWith(".")) return null; + + const parsed = Number(draft); + if (!Number.isFinite(parsed)) return null; + if (parsed < MIN_PLAYBACK_SPEED || parsed > MAX_PLAYBACK_SPEED) return null; + + return clampPlaybackSpeed(parsed); +}