This commit is contained in:
2026-05-02 18:40:13 +10:00
parent 80399c5a15
commit 2a0eea936d
3 changed files with 149 additions and 24 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { GripVertical, Trash2 } from "lucide-react";
function KvList({ values }) {
@@ -62,19 +62,105 @@ function valuesMatch(left, right) {
function ParameterField({ parameter, onParameterChange }) {
const [draftValue, setDraftValue] = useState(parameter.value);
const [appliedValue, setAppliedValue] = useState(parameter.value);
const pendingTimeoutRef = useRef(null);
const latestDraftRef = useRef(parameter.value);
const lastSentAtRef = useRef(0);
const isInteractingRef = useRef(false);
const isDirtyRef = useRef(false);
useEffect(() => {
setDraftValue(parameter.value);
}, [parameter.value]);
setAppliedValue(parameter.value);
latestDraftRef.current = parameter.value;
lastSentAtRef.current = 0;
isInteractingRef.current = false;
isDirtyRef.current = false;
}, [parameter.id]);
useEffect(() => {
setAppliedValue(parameter.value);
latestDraftRef.current = draftValue;
if (isDirtyRef.current && valuesMatch(parameter.value, latestDraftRef.current)) {
isDirtyRef.current = false;
}
if (!isInteractingRef.current && !isDirtyRef.current) {
setDraftValue(parameter.value);
}
}, [draftValue, parameter.value]);
useEffect(() => {
return () => {
if (pendingTimeoutRef.current) {
clearTimeout(pendingTimeoutRef.current);
}
};
}, []);
const sendValue = (value) => {
setDraftValue(value);
onParameterChange(parameter.id, value);
latestDraftRef.current = value;
isDirtyRef.current = true;
lastSentAtRef.current = Date.now();
const request = onParameterChange(parameter.id, value);
if (request && typeof request.then === "function") {
request
.then((response) => {
if (response?.ok) {
setAppliedValue(value);
}
})
.catch(() => {
// Keep showing the last confirmed value if the update failed.
});
}
};
const scheduleSendValue = (value, immediate = false) => {
setDraftValue(value);
latestDraftRef.current = value;
isDirtyRef.current = true;
if (pendingTimeoutRef.current) {
clearTimeout(pendingTimeoutRef.current);
pendingTimeoutRef.current = null;
}
const now = Date.now();
const throttleMs = 90;
const elapsed = now - lastSentAtRef.current;
if (immediate || elapsed >= throttleMs) {
sendValue(value);
return;
}
pendingTimeoutRef.current = setTimeout(() => {
pendingTimeoutRef.current = null;
sendValue(latestDraftRef.current);
}, throttleMs - elapsed);
};
const flushScheduledValue = () => {
if (pendingTimeoutRef.current) {
clearTimeout(pendingTimeoutRef.current);
pendingTimeoutRef.current = null;
}
sendValue(latestDraftRef.current);
};
const beginInteraction = () => {
isInteractingRef.current = true;
};
const endInteraction = () => {
isInteractingRef.current = false;
flushScheduledValue();
};
const label = <label>{parameter.label}</label>;
const isPending = !valuesMatch(draftValue, parameter.value);
const appliedValueText = formatParameterValue(parameter.type, parameter.value);
const isPending = !valuesMatch(draftValue, appliedValue);
const appliedValueText = formatParameterValue(parameter.type, appliedValue);
if (parameter.type === "float") {
return (
@@ -87,7 +173,16 @@ function ParameterField({ parameter, onParameterChange }) {
max={parameter.max?.[0] ?? 1}
step={parameter.step?.[0] ?? 0.01}
value={draftValue}
onChange={(event) => sendValue(Number(event.target.value))}
onMouseDown={beginInteraction}
onPointerDown={beginInteraction}
onTouchStart={beginInteraction}
onChange={(event) => scheduleSendValue(Number(event.target.value))}
onMouseUp={endInteraction}
onTouchEnd={endInteraction}
onPointerUp={endInteraction}
onKeyDown={beginInteraction}
onKeyUp={endInteraction}
onBlur={endInteraction}
/>
<input
type="number"
@@ -95,7 +190,9 @@ function ParameterField({ parameter, onParameterChange }) {
max={parameter.max?.[0] ?? ""}
step={parameter.step?.[0] ?? 0.01}
value={draftValue}
onFocus={beginInteraction}
onChange={(event) => sendValue(Number(event.target.value))}
onBlur={endInteraction}
/>
</div>
<div className={`parameter__value${isPending ? " parameter__value--pending" : ""}`}>
@@ -124,11 +221,13 @@ function ParameterField({ parameter, onParameterChange }) {
max={parameter.max?.[index] ?? ""}
step={parameter.step?.[index] ?? 0.01}
value={values[index]}
onFocus={beginInteraction}
onChange={(event) => {
const next = [...values];
next[index] = Number(event.target.value);
sendValue(next);
}}
onBlur={endInteraction}
/>
))}
</div>
@@ -147,7 +246,9 @@ function ParameterField({ parameter, onParameterChange }) {
<input
type="checkbox"
checked={Boolean(draftValue)}
onFocus={beginInteraction}
onChange={(event) => sendValue(event.target.checked)}
onBlur={endInteraction}
/>
<span>{draftValue ? "Enabled" : "Disabled"}</span>
</label>
@@ -162,7 +263,12 @@ function ParameterField({ parameter, onParameterChange }) {
return (
<section className="parameter">
{label}
<select value={draftValue} onChange={(event) => sendValue(event.target.value)}>
<select
value={draftValue}
onFocus={beginInteraction}
onChange={(event) => sendValue(event.target.value)}
onBlur={endInteraction}
>
{parameter.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
@@ -192,6 +298,7 @@ function LayerCard({
onDragOver,
onDrop,
onRemove,
onLayerParameterChange,
}) {
return (
<div
@@ -299,11 +406,9 @@ function LayerCard({
<div className="parameter-grid">
{layer.parameters.map((parameter) => (
<ParameterField
key={`${layer.id}:${parameter.id}:${JSON.stringify(parameter.value)}`}
key={`${layer.id}:${parameter.id}`}
parameter={parameter}
onParameterChange={(parameterId, value) =>
updateLayerParameterOptimistically(layer.id, parameterId, value)
}
onParameterChange={(parameterId, value) => onLayerParameterChange(layer.id, parameterId, value)}
/>
))}
</div>
@@ -405,7 +510,7 @@ function App() {
const expandedSet = useMemo(() => new Set(expandedLayerIds), [expandedLayerIds]);
function updateLayerParameterOptimistically(layerId, parameterId, value) {
postJson("/api/layers/update-parameter", {
return postJson("/api/layers/update-parameter", {
layerId,
parameterId,
value,
@@ -590,6 +695,7 @@ function App() {
onDragOver={setDropTargetLayerId}
onDrop={handleDrop}
onRemove={removeLayer}
onLayerParameterChange={updateLayerParameterOptimistically}
/>
))}