Browse Source

fix: prevent cursor jumping in ModelParameters text/number inputs

Use local state during editing and sync to store on blur, preventing
React Flow re-renders from resetting cursor position on every keystroke.
Also add click-to-seek on GenerateAudioNode waveform.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
6cc1807c72
  1. 6
      src/components/__tests__/ModelParameters.test.tsx
  2. 51
      src/components/nodes/ModelParameters.tsx

6
src/components/__tests__/ModelParameters.test.tsx

@ -284,9 +284,9 @@ describe("ModelParameters", () => {
expect(screen.getByRole("textbox")).toBeInTheDocument(); expect(screen.getByRole("textbox")).toBeInTheDocument();
}); });
fireEvent.change(screen.getByRole("textbox"), { const textbox = screen.getByRole("textbox");
target: { value: "new value" }, fireEvent.change(textbox, { target: { value: "new value" } });
}); fireEvent.blur(textbox);
expect(onParametersChange).toHaveBeenCalledWith({ prompt: "new value" }); expect(onParametersChange).toHaveBeenCalledWith({ prompt: "new value" });
}); });

51
src/components/nodes/ModelParameters.tsx

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { ProviderType, ModelInputDef } from "@/types"; import { ProviderType, ModelInputDef } from "@/types";
import { ModelParameter } from "@/lib/providers/types"; import { ModelParameter } from "@/lib/providers/types";
import { useProviderApiKeys } from "@/store/workflowStore"; import { useProviderApiKeys } from "@/store/workflowStore";
@ -250,13 +250,29 @@ interface ParameterInputProps {
} }
/** /**
* Individual parameter input based on type * Individual parameter input based on type.
* Text and number inputs use local state during editing to prevent
* cursor-jump issues caused by React Flow re-renders on store updates.
*/ */
function ParameterInput({ param, value, onChange }: ParameterInputProps) { function ParameterInput({ param, value, onChange }: ParameterInputProps) {
const displayName = param.name const displayName = param.name
.replace(/_/g, " ") .replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase()); .replace(/\b\w/g, (c) => c.toUpperCase());
// Local state for text/number inputs to prevent cursor jumping
const [localValue, setLocalValue] = useState<string>(() => {
if (value === undefined || value === null) return "";
return String(value);
});
const isFocusedRef = useRef(false);
// Sync from store when not focused (external changes)
useEffect(() => {
if (!isFocusedRef.current) {
setLocalValue(value === undefined || value === null ? "" : String(value));
}
}, [value]);
// Determine input type and render accordingly // Determine input type and render accordingly
if (param.enum && param.enum.length > 0) { if (param.enum && param.enum.length > 0) {
// Enum: render as select // Enum: render as select
@ -319,15 +335,13 @@ function ParameterInput({ param, value, onChange }: ParameterInputProps) {
} }
if (param.type === "number" || param.type === "integer") { if (param.type === "number" || param.type === "integer") {
// Number: render as number input with validation
const numValue = value !== undefined ? Number(value) : "";
const hasMin = param.minimum !== undefined; const hasMin = param.minimum !== undefined;
const hasMax = param.maximum !== undefined; const hasMax = param.maximum !== undefined;
// Validate current value against constraints // Validate current value against constraints
let validationError: string | null = null; let validationError: string | null = null;
if (value !== undefined && value !== "" && !isNaN(Number(value))) { if (localValue !== "" && !isNaN(Number(localValue))) {
const num = Number(value); const num = Number(localValue);
if (hasMin && num < param.minimum!) { if (hasMin && num < param.minimum!) {
validationError = `Min: ${param.minimum}`; validationError = `Min: ${param.minimum}`;
} else if (hasMax && num > param.maximum!) { } else if (hasMax && num > param.maximum!) {
@ -352,16 +366,20 @@ function ParameterInput({ param, value, onChange }: ParameterInputProps) {
</label> </label>
<input <input
type="number" type="number"
value={numValue} value={localValue}
min={param.minimum} min={param.minimum}
max={param.maximum} max={param.maximum}
step={param.type === "integer" ? 1 : 0.1} step={param.type === "integer" ? 1 : 0.1}
onFocus={() => { isFocusedRef.current = true; }}
onChange={(e) => { onChange={(e) => {
const val = e.target.value; setLocalValue(e.target.value);
if (val === "") { }}
onBlur={() => {
isFocusedRef.current = false;
if (localValue === "") {
onChange(undefined); onChange(undefined);
} else { } else {
const num = param.type === "integer" ? parseInt(val, 10) : parseFloat(val); const num = param.type === "integer" ? parseInt(localValue, 10) : parseFloat(localValue);
onChange(isNaN(num) ? undefined : num); onChange(isNaN(num) ? undefined : num);
} }
}} }}
@ -384,7 +402,7 @@ function ParameterInput({ param, value, onChange }: ParameterInputProps) {
return null; return null;
} }
// Default: string input // Default: string input — uses local state, syncs to store on blur
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<label <label
@ -395,8 +413,15 @@ function ParameterInput({ param, value, onChange }: ParameterInputProps) {
</label> </label>
<input <input
type="text" type="text"
value={(value as string) ?? ""} value={localValue}
onChange={(e) => onChange(e.target.value || undefined)} onFocus={() => { isFocusedRef.current = true; }}
onChange={(e) => {
setLocalValue(e.target.value);
}}
onBlur={() => {
isFocusedRef.current = false;
onChange(localValue || undefined);
}}
placeholder={param.default !== undefined ? `Default: ${param.default}` : undefined} placeholder={param.default !== undefined ? `Default: ${param.default}` : undefined}
className="nodrag nopan w-full text-[9px] py-0.5 px-1 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300 placeholder:text-neutral-600" className="nodrag nopan w-full text-[9px] py-0.5 px-1 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300 placeholder:text-neutral-600"
/> />

Loading…
Cancel
Save