added optional web component UI control
This commit is contained in:
@@ -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}
|
||||
|
||||
151
ui/src/components/ShaderCustomPanel.jsx
Normal file
151
ui/src/components/ShaderCustomPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user