fix: accept decimal custom speeds
This commit is contained in:
@@ -53,6 +53,7 @@ import ColorPicker from "../ui/color-picker";
|
||||
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
||||
import { BlurSettingsPanel } from "./BlurSettingsPanel";
|
||||
import { CropControl } from "./CropControl";
|
||||
import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed";
|
||||
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
@@ -71,7 +72,6 @@ import type {
|
||||
} from "./types";
|
||||
import {
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
MAX_ZOOM_SCALE,
|
||||
MIN_ZOOM_SCALE,
|
||||
ROTATION_3D_PRESET_ORDER,
|
||||
@@ -90,37 +90,38 @@ 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(isPreset ? "" : String(value));
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const prevValue = useRef(value);
|
||||
if (!isFocused && prevValue.current !== value) {
|
||||
prevValue.current = value;
|
||||
setDraft(isPreset ? "" : String(Math.round(value)));
|
||||
setDraft(isPreset ? "" : String(value));
|
||||
}
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const digits = e.target.value.replace(/\D/g, "");
|
||||
if (digits === "") {
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
const num = Number(digits);
|
||||
if (num > MAX_PLAYBACK_SPEED) {
|
||||
const result = parseCustomPlaybackSpeedInput(e.target.value);
|
||||
if (result.status === "too-fast") {
|
||||
onError();
|
||||
return;
|
||||
}
|
||||
setDraft(digits);
|
||||
if (num >= 1) onChange(num);
|
||||
|
||||
setDraft(result.draft);
|
||||
if (result.status === "valid") {
|
||||
onChange(result.speed);
|
||||
}
|
||||
},
|
||||
[onChange, onError],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsFocused(false);
|
||||
if (!draft || Number(draft) < 1) {
|
||||
setDraft(isPreset ? "" : String(Math.round(value)));
|
||||
const result = parseCustomPlaybackSpeedInput(draft);
|
||||
if (result.status === "valid") {
|
||||
setDraft(String(result.speed));
|
||||
} else {
|
||||
setDraft(isPreset ? "" : String(value));
|
||||
}
|
||||
}, [draft, isPreset, value]);
|
||||
|
||||
@@ -128,8 +129,8 @@ function CustomSpeedInput({
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*[.]?[0-9]*"
|
||||
placeholder="--"
|
||||
value={draft}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed";
|
||||
|
||||
describe("parseCustomPlaybackSpeedInput", () => {
|
||||
it("accepts decimal playback speeds", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("1.1")).toEqual({
|
||||
status: "valid",
|
||||
draft: "1.1",
|
||||
speed: 1.1,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a single decimal point while typing", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("1.2.3")).toEqual({
|
||||
status: "valid",
|
||||
draft: "1.23",
|
||||
speed: 1.23,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows sub-1 custom speeds down to the editor minimum", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("0.1")).toEqual({
|
||||
status: "valid",
|
||||
draft: "0.1",
|
||||
speed: 0.1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects speeds below the editor minimum", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("0.09")).toEqual({
|
||||
status: "too-slow",
|
||||
draft: "0.09",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts comma decimal input by normalizing to a dot", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("1,1")).toEqual({
|
||||
status: "valid",
|
||||
draft: "1.1",
|
||||
speed: 1.1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects speeds above the editor maximum", () => {
|
||||
expect(parseCustomPlaybackSpeedInput("16.1")).toEqual({
|
||||
status: "too-fast",
|
||||
draft: "16.1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
clampPlaybackSpeed,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
MIN_PLAYBACK_SPEED,
|
||||
type PlaybackSpeed,
|
||||
} from "./types";
|
||||
|
||||
export type CustomPlaybackSpeedInputResult =
|
||||
| { status: "empty"; draft: string }
|
||||
| { status: "too-fast"; draft: string }
|
||||
| { status: "too-slow"; draft: string }
|
||||
| { status: "valid"; draft: string; speed: PlaybackSpeed };
|
||||
|
||||
export function parseCustomPlaybackSpeedInput(rawValue: string): CustomPlaybackSpeedInputResult {
|
||||
const decimalDraft = rawValue.replace(/,/g, ".").replace(/[^\d.]/g, "");
|
||||
const [whole = "", ...fractionParts] = decimalDraft.split(".");
|
||||
const draft = fractionParts.length > 0 ? `${whole}.${fractionParts.join("")}` : whole;
|
||||
|
||||
if (draft === "" || draft === ".") {
|
||||
return { status: "empty", draft };
|
||||
}
|
||||
|
||||
const speed = Number(draft);
|
||||
if (!Number.isFinite(speed)) {
|
||||
return { status: "empty", draft };
|
||||
}
|
||||
|
||||
if (speed > MAX_PLAYBACK_SPEED) {
|
||||
return { status: "too-fast", draft };
|
||||
}
|
||||
|
||||
if (speed < MIN_PLAYBACK_SPEED) {
|
||||
return { status: "too-slow", draft };
|
||||
}
|
||||
|
||||
return { status: "valid", draft, speed: clampPlaybackSpeed(speed) };
|
||||
}
|
||||
@@ -104,8 +104,9 @@
|
||||
"gifButton": "Esporta GIF",
|
||||
"chooseSaveLocation": "Scegli posizione di salvataggio"
|
||||
},
|
||||
"links": {
|
||||
"support": {
|
||||
"reportBug": "Segnala bug",
|
||||
"saveDiagnostics": "Salva dati diagnostici",
|
||||
"starOnGithub": "Metti stella su GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
|
||||
Reference in New Issue
Block a user