From c39a1fd53caca7f50d5b1cb77be67adc5c524552 Mon Sep 17 00:00:00 2001 From: Aiden Date: Sun, 3 May 2026 11:26:10 +1000 Subject: [PATCH] CI/CD --- .gitea/workflows/ci.yml | 47 ++ ui/src/App.jsx | 728 +------------------- ui/src/api/controlApi.js | 7 + ui/src/components/KvList.jsx | 18 + ui/src/components/LayerCard.jsx | 154 +++++ ui/src/components/LayerStack.jsx | 152 ++++ ui/src/components/ParameterField.jsx | 130 ++++ ui/src/components/ParameterValueDisplay.jsx | 25 + ui/src/components/StackPresetToolbar.jsx | 75 ++ ui/src/components/StatusPanels.jsx | 44 ++ ui/src/hooks/useRuntimeState.js | 55 ++ ui/src/hooks/useThrottledParameterValue.js | 114 +++ 12 files changed, 848 insertions(+), 701 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 ui/src/api/controlApi.js create mode 100644 ui/src/components/KvList.jsx create mode 100644 ui/src/components/LayerCard.jsx create mode 100644 ui/src/components/LayerStack.jsx create mode 100644 ui/src/components/ParameterField.jsx create mode 100644 ui/src/components/ParameterValueDisplay.jsx create mode 100644 ui/src/components/StackPresetToolbar.jsx create mode 100644 ui/src/components/StatusPanels.jsx create mode 100644 ui/src/hooks/useRuntimeState.js create mode 100644 ui/src/hooks/useThrottledParameterValue.js diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..b6f2440 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: + - main + - master + - develop + pull_request: + workflow_dispatch: + +jobs: + native-windows: + name: Native Windows Build And Tests + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure Debug + shell: pwsh + run: cmake --preset vs2022-x64-debug + + - name: Build Debug + shell: pwsh + run: cmake --build --preset build-debug + + - name: Run Native Tests + shell: pwsh + run: cmake --build --preset build-debug --target RUN_TESTS + + ui-ubuntu: + name: React UI Build + runs-on: nubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install UI Dependencies + working-directory: ui + run: npm ci --no-audit --no-fund + + - name: Build UI + working-directory: ui + run: npm run build diff --git a/ui/src/App.jsx b/ui/src/App.jsx index ed3e65e..1db12d4 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -1,442 +1,12 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { GripVertical, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; -function KvList({ values }) { - return ( -
- {values.map(([key, value]) => ( - - ))} -
- ); -} - -function FragmentRow({ label, value }) { - return ( - <> -
{label}
-
{value}
- - ); -} - -function postJson(path, payload) { - return fetch(path, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -} - -function moveItem(array, fromIndex, toIndex) { - if (fromIndex < 0 || fromIndex >= array.length || toIndex < 0 || toIndex >= array.length) { - return array; - } - - const copy = [...array]; - const [item] = copy.splice(fromIndex, 1); - copy.splice(toIndex, 0, item); - return copy; -} - -function formatNumber(value, digits = 3) { - return Number(value ?? 0).toFixed(digits); -} - -function formatParameterValue(parameterType, value) { - if (parameterType === "float") { - return formatNumber(value); - } - if (parameterType === "vec2" || parameterType === "color") { - return (value ?? []).map((item) => formatNumber(item)).join(", "); - } - if (parameterType === "bool") { - return value ? "Enabled" : "Disabled"; - } - return `${value ?? ""}`; -} - -function valuesMatch(left, right) { - return JSON.stringify(left) === JSON.stringify(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); - 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); - 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 = ; - const isPending = !valuesMatch(draftValue, appliedValue); - const appliedValueText = formatParameterValue(parameter.type, appliedValue); - - if (parameter.type === "float") { - return ( -
- {label} -
- scheduleSendValue(Number(event.target.value))} - onMouseUp={endInteraction} - onTouchEnd={endInteraction} - onPointerUp={endInteraction} - onKeyDown={beginInteraction} - onKeyUp={endInteraction} - onBlur={endInteraction} - /> - sendValue(Number(event.target.value))} - onBlur={endInteraction} - /> -
-
- {isPending ? `Applied: ${appliedValueText}` : appliedValueText} -
-
- ); - } - - if (parameter.type === "vec2" || parameter.type === "color") { - const componentCount = parameter.type === "color" ? 4 : 2; - const values = [...(draftValue ?? [])]; - while (values.length < componentCount) { - values.push(0); - } - - return ( -
- {label} -
- {Array.from({ length: componentCount }, (_, index) => ( - { - const next = [...values]; - next[index] = Number(event.target.value); - sendValue(next); - }} - onBlur={endInteraction} - /> - ))} -
-
- {isPending ? `Applied: ${appliedValueText}` : appliedValueText} -
-
- ); - } - - if (parameter.type === "bool") { - return ( -
- {label} - -
- {isPending ? `Applied: ${appliedValueText}` : appliedValueText} -
-
- ); - } - - if (parameter.type === "enum") { - return ( -
- {label} - -
- {isPending ? `Applied: ${appliedValueText}` : appliedValueText} -
-
- ); - } - - return null; -} - -function LayerCard({ - layer, - index, - shaders, - expanded, - isDragging, - isDropTarget, - onToggleExpanded, - onDragStart, - onDragEnd, - onDragOver, - onDrop, - onRemove, - onLayerParameterChange, -}) { - return ( -
{ - event.preventDefault(); - onDragOver(layer.id); - }} - onDrop={(event) => { - event.preventDefault(); - onDrop(event, layer.id, index); - }} - > -
-
- - {index + 1} - -
- -
- - - - -
-
- - {expanded ? ( -
-
- - -
- - {layer.temporal?.enabled ? ( -
- -
- {layer.temporal.historySource} history, requested {layer.temporal.requestedHistoryLength} frame{layer.temporal.requestedHistoryLength === 1 ? "" : "s"}, using {layer.temporal.effectiveHistoryLength} -
-
- ) : ( -
- -
Stateless shader
-
- )} - -
-

Parameters

- -
- - {layer.parameters.length > 0 ? ( -
- {layer.parameters.map((parameter) => ( - onLayerParameterChange(layer.id, parameterId, value)} - /> - ))} -
- ) : ( -

This shader does not expose any user parameters.

- )} -
- ) : null} -
- ); -} +import { LayerStack } from "./components/LayerStack"; +import { StackPresetToolbar } from "./components/StackPresetToolbar"; +import { StatusPanels } from "./components/StatusPanels"; +import { useRuntimeState } from "./hooks/useRuntimeState"; function App() { - const [appState, setAppState] = useState(null); + const [appState, setAppState] = useRuntimeState(); const [pendingShaderId, setPendingShaderId] = useState(""); const [presetName, setPresetName] = useState(""); const [selectedPresetName, setSelectedPresetName] = useState(""); @@ -444,54 +14,6 @@ function App() { const [dragLayerId, setDragLayerId] = useState(null); const [dropTargetLayerId, setDropTargetLayerId] = useState(null); - useEffect(() => { - let mounted = true; - let socket; - let retryTimer; - - const connectWebSocket = () => { - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - socket = new WebSocket(`${protocol}://${window.location.host}/ws`); - socket.onmessage = (event) => { - try { - const nextState = JSON.parse(event.data); - if (mounted) { - setAppState(nextState); - } - } catch (error) { - console.error("Failed to parse state update", error); - } - }; - socket.onclose = () => { - if (mounted) { - retryTimer = window.setTimeout(connectWebSocket, 1000); - } - }; - }; - - fetch("/api/state") - .then((response) => response.json()) - .then((state) => { - if (mounted) { - setAppState(state); - connectWebSocket(); - } - }) - .catch((error) => { - console.error("Failed to load initial state", error); - }); - - return () => { - mounted = false; - if (retryTimer) { - window.clearTimeout(retryTimer); - } - if (socket) { - socket.close(); - } - }; - }, []); - const layers = appState?.layers ?? []; const shaders = appState?.shaders ?? []; const performance = appState?.performance ?? {}; @@ -521,60 +43,6 @@ function App() { setExpandedLayerIds((current) => current.filter((layerId) => layerIds.has(layerId))); }, [layers]); - const expandedSet = useMemo(() => new Set(expandedLayerIds), [expandedLayerIds]); - - function updateLayerParameterOptimistically(layerId, parameterId, value) { - return postJson("/api/layers/update-parameter", { - layerId, - parameterId, - value, - }); - } - - function toggleExpanded(layerId) { - setExpandedLayerIds((current) => - current.includes(layerId) ? current.filter((id) => id !== layerId) : [...current, layerId], - ); - } - - function removeLayer(layerId) { - setExpandedLayerIds((current) => current.filter((id) => id !== layerId)); - postJson("/api/layers/remove", { layerId }); - } - - function handleDrop(event, targetLayerId, targetIndex) { - const sourceLayerId = event.dataTransfer.getData("text/plain") || dragLayerId; - if (!sourceLayerId || sourceLayerId === targetLayerId) { - setDragLayerId(null); - setDropTargetLayerId(null); - return; - } - - setAppState((current) => { - if (!current?.layers) { - return current; - } - - const sourceIndex = current.layers.findIndex((layer) => layer.id === sourceLayerId); - const destinationIndex = current.layers.findIndex((layer) => layer.id === targetLayerId); - if (sourceIndex < 0 || destinationIndex < 0 || sourceIndex === destinationIndex) { - return current; - } - - return { - ...current, - layers: moveItem(current.layers, sourceIndex, destinationIndex), - }; - }); - - postJson("/api/layers/reorder", { - layerId: sourceLayerId, - targetIndex, - }); - setDragLayerId(null); - setDropTargetLayerId(null); - } - if (!appState) { return (
@@ -588,171 +56,29 @@ function App() { return (
-
-
- -
- setPresetName(event.target.value)} - /> - -
-
+ -
- -
- - -
-
+ - -
- -
-
-

Runtime

- -
- -
-

Video

- -
- -
-

Compiler

-
{runtime.compileMessage || "No compiler output."}
-
-
- -
-
-

Layers

-

Drag layers to reorder them. Each layer processes the output of the one above it.

-
- -
- {layers.map((layer, index) => ( - { - setDragLayerId(null); - setDropTargetLayerId(null); - }} - onDragOver={setDropTargetLayerId} - onDrop={handleDrop} - onRemove={removeLayer} - onLayerParameterChange={updateLayerParameterOptimistically} - /> - ))} - -
-
-
- + -
Add Layer
-
-
- -
-
-
-
- - -
-
-
-
-
+
); } diff --git a/ui/src/api/controlApi.js b/ui/src/api/controlApi.js new file mode 100644 index 0000000..a124cf3 --- /dev/null +++ b/ui/src/api/controlApi.js @@ -0,0 +1,7 @@ +export function postJson(path, payload) { + return fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} diff --git a/ui/src/components/KvList.jsx b/ui/src/components/KvList.jsx new file mode 100644 index 0000000..7a25222 --- /dev/null +++ b/ui/src/components/KvList.jsx @@ -0,0 +1,18 @@ +export function KvList({ values }) { + return ( +
+ {values.map(([key, value]) => ( + + ))} +
+ ); +} + +function FragmentRow({ label, value }) { + return ( + <> +
{label}
+
{value}
+ + ); +} diff --git a/ui/src/components/LayerCard.jsx b/ui/src/components/LayerCard.jsx new file mode 100644 index 0000000..0c8bea2 --- /dev/null +++ b/ui/src/components/LayerCard.jsx @@ -0,0 +1,154 @@ +import { GripVertical, Trash2 } from "lucide-react"; + +import { postJson } from "../api/controlApi"; +import { ParameterField } from "./ParameterField"; + +export function LayerCard({ + layer, + index, + shaders, + expanded, + isDragging, + isDropTarget, + onToggleExpanded, + onDragStart, + onDragEnd, + onDragOver, + onDrop, + onRemove, + onLayerParameterChange, +}) { + return ( +
{ + event.preventDefault(); + onDragOver(layer.id); + }} + onDrop={(event) => { + event.preventDefault(); + onDrop(event, layer.id, index); + }} + > +
+
+ + {index + 1} + +
+ +
+ + + + +
+
+ + {expanded ? ( +
+
+ + +
+ + {layer.temporal?.enabled ? ( +
+ +
+ {layer.temporal.historySource} history, requested {layer.temporal.requestedHistoryLength} frame{layer.temporal.requestedHistoryLength === 1 ? "" : "s"}, using {layer.temporal.effectiveHistoryLength} +
+
+ ) : ( +
+ +
Stateless shader
+
+ )} + +
+

Parameters

+ +
+ + {layer.parameters.length > 0 ? ( +
+ {layer.parameters.map((parameter) => ( + onLayerParameterChange(layer.id, parameterId, value)} + /> + ))} +
+ ) : ( +

This shader does not expose any user parameters.

+ )} +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/LayerStack.jsx b/ui/src/components/LayerStack.jsx new file mode 100644 index 0000000..8f21f42 --- /dev/null +++ b/ui/src/components/LayerStack.jsx @@ -0,0 +1,152 @@ +import { postJson } from "../api/controlApi"; +import { LayerCard } from "./LayerCard"; + +function moveItem(array, fromIndex, toIndex) { + if (fromIndex < 0 || fromIndex >= array.length || toIndex < 0 || toIndex >= array.length) { + return array; + } + + const copy = [...array]; + const [item] = copy.splice(fromIndex, 1); + copy.splice(toIndex, 0, item); + return copy; +} + +export function LayerStack({ + dragLayerId, + dropTargetLayerId, + expandedLayerIds, + layers, + pendingShaderId, + setAppState, + setDragLayerId, + setDropTargetLayerId, + setExpandedLayerIds, + setPendingShaderId, + shaders, +}) { + const expandedSet = new Set(expandedLayerIds); + + function updateLayerParameterOptimistically(layerId, parameterId, value) { + return postJson("/api/layers/update-parameter", { + layerId, + parameterId, + value, + }); + } + + function toggleExpanded(layerId) { + setExpandedLayerIds((current) => + current.includes(layerId) ? current.filter((id) => id !== layerId) : [...current, layerId], + ); + } + + function removeLayer(layerId) { + setExpandedLayerIds((current) => current.filter((id) => id !== layerId)); + postJson("/api/layers/remove", { layerId }); + } + + function handleDrop(event, targetLayerId, targetIndex) { + const sourceLayerId = event.dataTransfer.getData("text/plain") || dragLayerId; + if (!sourceLayerId || sourceLayerId === targetLayerId) { + setDragLayerId(null); + setDropTargetLayerId(null); + return; + } + + setAppState((current) => { + if (!current?.layers) { + return current; + } + + const sourceIndex = current.layers.findIndex((layer) => layer.id === sourceLayerId); + const destinationIndex = current.layers.findIndex((layer) => layer.id === targetLayerId); + if (sourceIndex < 0 || destinationIndex < 0 || sourceIndex === destinationIndex) { + return current; + } + + return { + ...current, + layers: moveItem(current.layers, sourceIndex, destinationIndex), + }; + }); + + postJson("/api/layers/reorder", { + layerId: sourceLayerId, + targetIndex, + }); + setDragLayerId(null); + setDropTargetLayerId(null); + } + + return ( +
+
+

Layers

+

Drag layers to reorder them. Each layer processes the output of the one above it.

+
+ +
+ {layers.map((layer, index) => ( + { + setDragLayerId(null); + setDropTargetLayerId(null); + }} + onDragOver={setDropTargetLayerId} + onDrop={handleDrop} + onRemove={removeLayer} + onLayerParameterChange={updateLayerParameterOptimistically} + /> + ))} + +
+
+
+ + +
Add Layer
+
+
+ +
+
+
+
+ + +
+
+
+
+
+ ); +} diff --git a/ui/src/components/ParameterField.jsx b/ui/src/components/ParameterField.jsx new file mode 100644 index 0000000..1651718 --- /dev/null +++ b/ui/src/components/ParameterField.jsx @@ -0,0 +1,130 @@ +import { useThrottledParameterValue } from "../hooks/useThrottledParameterValue"; +import { ParameterValueDisplay } from "./ParameterValueDisplay"; + +export function ParameterField({ parameter, onParameterChange }) { + const { + appliedValue, + beginInteraction, + draftValue, + endInteraction, + isPending, + scheduleSendValue, + sendValue, + } = useThrottledParameterValue(parameter, onParameterChange); + + const label = ; + + if (parameter.type === "float") { + return ( +
+ {label} +
+ scheduleSendValue(Number(event.target.value))} + onMouseUp={endInteraction} + onTouchEnd={endInteraction} + onPointerUp={endInteraction} + onKeyDown={beginInteraction} + onKeyUp={endInteraction} + onBlur={endInteraction} + /> + sendValue(Number(event.target.value))} + onBlur={endInteraction} + /> +
+ +
+ ); + } + + if (parameter.type === "vec2" || parameter.type === "color") { + const componentCount = parameter.type === "color" ? 4 : 2; + const values = [...(draftValue ?? [])]; + while (values.length < componentCount) { + values.push(0); + } + + return ( +
+ {label} +
+ {Array.from({ length: componentCount }, (_, index) => ( + { + const next = [...values]; + next[index] = Number(event.target.value); + sendValue(next); + }} + onBlur={endInteraction} + /> + ))} +
+ +
+ ); + } + + if (parameter.type === "bool") { + return ( +
+ {label} + + +
+ ); + } + + if (parameter.type === "enum") { + return ( +
+ {label} + + +
+ ); + } + + return null; +} diff --git a/ui/src/components/ParameterValueDisplay.jsx b/ui/src/components/ParameterValueDisplay.jsx new file mode 100644 index 0000000..8dab0f5 --- /dev/null +++ b/ui/src/components/ParameterValueDisplay.jsx @@ -0,0 +1,25 @@ +function formatNumber(value, digits = 3) { + return Number(value ?? 0).toFixed(digits); +} + +export function formatParameterValue(parameterType, value) { + if (parameterType === "float") { + return formatNumber(value); + } + if (parameterType === "vec2" || parameterType === "color") { + return (value ?? []).map((item) => formatNumber(item)).join(", "); + } + if (parameterType === "bool") { + return value ? "Enabled" : "Disabled"; + } + return `${value ?? ""}`; +} + +export function ParameterValueDisplay({ parameterType, value, pending }) { + const valueText = formatParameterValue(parameterType, value); + return ( +
+ {pending ? `Applied: ${valueText}` : valueText} +
+ ); +} diff --git a/ui/src/components/StackPresetToolbar.jsx b/ui/src/components/StackPresetToolbar.jsx new file mode 100644 index 0000000..61f3639 --- /dev/null +++ b/ui/src/components/StackPresetToolbar.jsx @@ -0,0 +1,75 @@ +import { postJson } from "../api/controlApi"; + +export function StackPresetToolbar({ + presetName, + selectedPresetName, + stackPresets, + onPresetNameChange, + onSelectedPresetNameChange, +}) { + return ( +
+
+ +
+ onPresetNameChange(event.target.value)} + /> + +
+
+ +
+ +
+ + +
+
+ + +
+ ); +} diff --git a/ui/src/components/StatusPanels.jsx b/ui/src/components/StatusPanels.jsx new file mode 100644 index 0000000..3f859c1 --- /dev/null +++ b/ui/src/components/StatusPanels.jsx @@ -0,0 +1,44 @@ +import { KvList } from "./KvList"; + +function formatNumber(value, digits = 3) { + return Number(value ?? 0).toFixed(digits); +} + +export function StatusPanels({ app, performance, runtime, video }) { + return ( +
+
+

Runtime

+ +
+ +
+

Video

+ +
+ +
+

Compiler

+
{runtime.compileMessage || "No compiler output."}
+
+
+ ); +} diff --git a/ui/src/hooks/useRuntimeState.js b/ui/src/hooks/useRuntimeState.js new file mode 100644 index 0000000..59a367c --- /dev/null +++ b/ui/src/hooks/useRuntimeState.js @@ -0,0 +1,55 @@ +import { useEffect, useState } from "react"; + +export function useRuntimeState() { + const [appState, setAppState] = useState(null); + + useEffect(() => { + let mounted = true; + let socket; + let retryTimer; + + const connectWebSocket = () => { + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + socket = new WebSocket(`${protocol}://${window.location.host}/ws`); + socket.onmessage = (event) => { + try { + const nextState = JSON.parse(event.data); + if (mounted) { + setAppState(nextState); + } + } catch (error) { + console.error("Failed to parse state update", error); + } + }; + socket.onclose = () => { + if (mounted) { + retryTimer = window.setTimeout(connectWebSocket, 1000); + } + }; + }; + + fetch("/api/state") + .then((response) => response.json()) + .then((state) => { + if (mounted) { + setAppState(state); + connectWebSocket(); + } + }) + .catch((error) => { + console.error("Failed to load initial state", error); + }); + + return () => { + mounted = false; + if (retryTimer) { + window.clearTimeout(retryTimer); + } + if (socket) { + socket.close(); + } + }; + }, []); + + return [appState, setAppState]; +} diff --git a/ui/src/hooks/useThrottledParameterValue.js b/ui/src/hooks/useThrottledParameterValue.js new file mode 100644 index 0000000..c8e8121 --- /dev/null +++ b/ui/src/hooks/useThrottledParameterValue.js @@ -0,0 +1,114 @@ +import { useEffect, useRef, useState } from "react"; + +function valuesMatch(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} + +export function useThrottledParameterValue(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); + 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); + 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(); + }; + + return { + appliedValue, + beginInteraction, + draftValue, + endInteraction, + isPending: !valuesMatch(draftValue, appliedValue), + scheduleSendValue, + sendValue, + }; +}