added optional web component UI control
All checks were successful
CI / React UI Build (push) Successful in 12s
CI / Native Windows Build And Tests (push) Successful in 2m20s
CI / Windows Release Package (push) Successful in 2m56s

This commit is contained in:
Aiden
2026-05-30 22:57:59 +10:00
parent a6d2ee385e
commit 27690c3afa
26 changed files with 804 additions and 76 deletions

View File

@@ -2,6 +2,7 @@ import { EyeOff, GripVertical, RotateCcw, SlidersHorizontal, Trash2 } from "luci
import { postJson } from "../api/controlApi";
import { ParameterField } from "./ParameterField";
import { ShaderCustomPanel } from "./ShaderCustomPanel";
export function LayerCard({
layer,
@@ -19,6 +20,24 @@ export function LayerCard({
onLayerParameterChange,
}) {
const selectedShader = shaders.find((shader) => shader.id === layer.shaderId);
const customUi = layer.ui?.type === "webComponent" ? layer.ui : selectedShader?.ui;
const hasCustomUi = customUi?.type === "webComponent" && customUi.assetUrl && customUi.tag;
const updateParameter = (parameterId, value) => onLayerParameterChange(layer.id, parameterId, value);
const resetParameters = () => postJson("/api/layers/reset-parameters", { layerId: layer.id });
const parameterControls = layer.parameters.length > 0 ? (
<div className="parameter-grid">
{layer.parameters.map((parameter) => (
<ParameterField
key={`${layer.id}:${parameter.id}`}
layer={layer}
parameter={parameter}
onParameterChange={updateParameter}
/>
))}
</div>
) : (
<p className="muted">This shader does not expose any user parameters.</p>
);
return (
<div
@@ -120,26 +139,24 @@ export function LayerCard({
type="button"
className="button-with-icon"
disabled={layer.parameters.length === 0}
onClick={() => postJson("/api/layers/reset-parameters", { layerId: layer.id })}
onClick={resetParameters}
>
<RotateCcw size={16} strokeWidth={1.9} aria-hidden="true" />
<span>Reset</span>
</button>
</div>
{layer.parameters.length > 0 ? (
<div className="parameter-grid">
{layer.parameters.map((parameter) => (
<ParameterField
key={`${layer.id}:${parameter.id}`}
layer={layer}
parameter={parameter}
onParameterChange={(parameterId, value) => onLayerParameterChange(layer.id, parameterId, value)}
/>
))}
</div>
{hasCustomUi ? (
<ShaderCustomPanel
layer={layer}
ui={customUi}
onParameterChange={updateParameter}
onResetParameters={resetParameters}
>
{parameterControls}
</ShaderCustomPanel>
) : (
<p className="muted">This shader does not expose any user parameters.</p>
parameterControls
)}
</div>
) : null}

View File

@@ -0,0 +1,151 @@
import { useEffect, useMemo, useState } from "react";
const moduleLoadCache = new Map();
function loadCustomElement(ui) {
if (!ui?.assetUrl || !ui?.tag) {
return Promise.reject(new Error("Custom UI metadata is incomplete."));
}
if (customElements.get(ui.tag)) {
return Promise.resolve();
}
if (!moduleLoadCache.has(ui.assetUrl)) {
moduleLoadCache.set(ui.assetUrl, import(/* @vite-ignore */ ui.assetUrl));
}
return moduleLoadCache.get(ui.assetUrl).then(() => {
if (!customElements.get(ui.tag)) {
throw new Error(`Custom UI module did not register ${ui.tag}.`);
}
});
}
function parameterMap(parameters) {
return Object.fromEntries((parameters ?? []).map((parameter) => [parameter.id, parameter.value]));
}
export function ShaderCustomPanel({ layer, ui, onParameterChange, onResetParameters, children }) {
const [useDefaultControls, setUseDefaultControls] = useState(false);
const [loadError, setLoadError] = useState("");
const [element, setElement] = useState(null);
const values = useMemo(() => parameterMap(layer.parameters), [layer.parameters]);
useEffect(() => {
if (!ui?.assetUrl || !ui?.tag) {
setElement(null);
return undefined;
}
let cancelled = false;
let customElement = null;
setLoadError("");
setElement(null);
setUseDefaultControls(false);
loadCustomElement(ui)
.then(() => {
if (cancelled) {
return;
}
customElement = document.createElement(ui.tag);
customElement.className = "shader-custom-ui__element";
setElement(customElement);
})
.catch((error) => {
if (!cancelled) {
setLoadError(error instanceof Error ? error.message : "Custom controls failed to load.");
}
});
return () => {
cancelled = true;
customElement?.remove();
};
}, [ui?.assetUrl, ui?.tag]);
useEffect(() => {
if (!element) {
return undefined;
}
function handleParameterChange(event) {
const detail = event.detail ?? {};
const parameterId = detail.parameterId ?? detail.id;
if (parameterId) {
onParameterChange(parameterId, detail.value);
}
}
function handleReset() {
onResetParameters();
}
element.addEventListener("shader-parameter-change", handleParameterChange);
element.addEventListener("shader-reset-parameters", handleReset);
return () => {
element.removeEventListener("shader-parameter-change", handleParameterChange);
element.removeEventListener("shader-reset-parameters", handleReset);
};
}, [element, onParameterChange, onResetParameters]);
useEffect(() => {
if (!element) {
return;
}
element.layer = layer;
element.parameters = layer.parameters ?? [];
element.values = values;
element.setParameter = onParameterChange;
element.requestReset = onResetParameters;
element.dispatchEvent(new CustomEvent("shader-layer-update", {
detail: {
layer,
parameters: layer.parameters ?? [],
values,
},
}));
}, [element, layer, onParameterChange, onResetParameters, values]);
if (!ui?.assetUrl || !ui?.tag) {
return children;
}
if (useDefaultControls || loadError) {
return (
<div className="shader-custom-ui">
{!loadError ? (
<div className="shader-custom-ui__toolbar">
<button type="button" className="button-with-icon" onClick={() => setUseDefaultControls(false)}>
<span>Custom UI</span>
</button>
</div>
) : null}
{loadError ? <p className="shader-custom-ui__status">Custom controls unavailable; default controls shown.</p> : null}
{children}
</div>
);
}
return (
<div className="shader-custom-ui">
<div className="shader-custom-ui__toolbar">
<button type="button" className="button-with-icon" onClick={() => setUseDefaultControls(true)}>
<span>Default controls</span>
</button>
</div>
<div
className="shader-custom-ui__host"
ref={(node) => {
if (!node || !element) {
return;
}
if (element.parentNode !== node) {
node.replaceChildren(element);
}
}}
>
{!element ? <p className="shader-custom-ui__status">Loading custom controls.</p> : null}
</div>
</div>
);
}

View File

@@ -907,6 +907,41 @@ pre {
overflow-wrap: anywhere;
}
.shader-custom-ui {
display: grid;
gap: 0.65rem;
}
.shader-custom-ui__toolbar {
display: flex;
justify-content: flex-end;
}
.shader-custom-ui__toolbar button {
width: auto;
min-width: var(--button-min-width);
}
.shader-custom-ui__host {
min-width: 0;
min-height: 3rem;
padding: 0.65rem;
border: 1px solid var(--app-border);
border-radius: var(--app-radius);
background: #141a23;
}
.shader-custom-ui__element {
display: block;
width: 100%;
}
.shader-custom-ui__status {
margin: 0;
color: var(--app-muted);
font-size: 0.84rem;
}
.shader-picker__topline {
display: flex;
align-items: baseline;