feat(custom-widgets): improve authoring workbench

This commit is contained in:
ajnart committed 2026-08-23 22:25:54 +02:00
1 parent ec295fe26e
commit 2153069a2f
35 files changed
+2615 -483

No files matched your search

@@ -1,5 +1,5 @@
import type { ComponentType, ErrorInfo, ReactNode } from "react";
import { Component, useCallback, useEffect, useId, useMemo, useState } from "react";
import { Component, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { Alert, Box, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
@@ -115,7 +115,18 @@ export function CustomJsxRenderer(props: CustomJsxRendererProps) {
</Alert>
);
return <CustomJsxRendererSession key={props.template} {...props} />;
return <CustomJsxRendererSession {...props} />;
}
interface InputRegistration {
name: string;
type: WidgetInputType;
initialValue: WidgetInputValue;
}
interface InputState {
values: Record<string, WidgetInputValue>;
types: Record<string, WidgetInputType>;
}
function CustomJsxRendererSession({
@@ -130,38 +141,74 @@ function CustomJsxRendererSession({
const inputScopeId = useId();
const [parseErrors, setParseErrors] = useState<string[]>([]);
const [bindingErrors, setBindingErrors] = useState<string[]>([]);
const [inputs, setInputs] = useState<Record<string, WidgetInputValue>>({});
const [inputTypes, setInputTypes] = useState<Record<string, WidgetInputType>>({});
const registerInput = useCallback((name: string, type: WidgetInputType, initialValue: WidgetInputValue) => {
setInputTypes((current) => {
const existing = current[name];
if (existing === type) return current;
if (existing && existing !== type) {
setBindingErrors((errors) => {
const message = `BINDING_TYPE_CONFLICT: '${name}' is bound as both ${existing} and ${type}`;
return errors.includes(message) ? errors : [...errors.slice(0, 4), message];
});
return current;
}
return { ...current, [name]: type };
});
setInputs((current) => (Object.hasOwn(current, name) ? current : { ...current, [name]: initialValue }));
const [inputState, setInputState] = useState<InputState>({ values: {}, types: {} });
const { values: inputs, types: inputTypes } = inputState;
const registrations = useRef(new Map<symbol, InputRegistration>());
const registrationVersion = useRef(0);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const setInputValue = useCallback(
(name: string, type: WidgetInputType, value: WidgetInputValue) => {
const existing = inputTypes[name];
if (existing && existing !== type) {
setBindingErrors((errors) => {
const message = `BINDING_TYPE_CONFLICT: '${name}' is bound as both ${existing} and ${type}`;
return errors.includes(message) ? errors : [...errors.slice(0, 4), message];
});
return;
const reconcileInputs = useCallback(() => {
if (!mounted.current) return;
const active = new Map<string, InputRegistration>();
const conflicts: string[] = [];
for (const registration of registrations.current.values()) {
const existing = active.get(registration.name);
if (!existing) {
active.set(registration.name, registration);
continue;
}
setInputTypes((current) => (current[name] === type ? current : { ...current, [name]: type }));
setInputs((current) => (Object.is(current[name], value) ? current : { ...current, [name]: value }));
if (existing.type === registration.type) continue;
const message = `BINDING_TYPE_CONFLICT: '${registration.name}' is bound as both ${existing.type} and ${registration.type}`;
if (!conflicts.includes(message) && conflicts.length < 5) conflicts.push(message);
}
setBindingErrors((current) => (sameStringArray(current, conflicts) ? current : conflicts));
setInputState((current) => {
const nextTypes: Record<string, WidgetInputType> = {};
for (const [name, registration] of active) nextTypes[name] = registration.type;
const nextValues: Record<string, WidgetInputValue> = {};
for (const [name, registration] of active) {
nextValues[name] =
current.types[name] === registration.type && Object.hasOwn(current.values, name)
? current.values[name]
: registration.initialValue;
}
if (sameTypeRecord(current.types, nextTypes) && sameInputRecord(current.values, nextValues)) return current;
return { values: nextValues, types: nextTypes };
});
}, []);
const registerInput = useCallback(
(name: string, type: WidgetInputType, initialValue: WidgetInputValue) => {
const id = Symbol(name);
registrations.current.set(id, { name, type, initialValue });
registrationVersion.current += 1;
reconcileInputs();
return () => {
registrations.current.delete(id);
registrationVersion.current += 1;
const version = registrationVersion.current;
queueMicrotask(() => {
if (registrationVersion.current === version) reconcileInputs();
});
};
},
[inputTypes],
[reconcileInputs],
);
const setInputValue = useCallback((name: string, type: WidgetInputType, value: WidgetInputValue) => {
setInputState((current) => {
const existing = current.types[name];
if (existing && existing !== type) return current;
if (existing === type && Object.is(current.values[name], value)) return current;
return {
values: { ...current.values, [name]: value },
types: existing === type ? current.types : { ...current.types, [name]: type },
};
});
}, []);
const rendered = useMemo(() => {
try {
const bindings = { ...createBindings(data), status, options, inputs };
@@ -234,3 +281,21 @@ function createBoundaryKey(template: string, bindings: Readonly<Record<string, u
for (let index = 0; index < value.length; index += 1) hash = (hash * 31 + value.charCodeAt(index)) | 0;
return `${template.length}:${hash}`;
}
function sameStringArray(left: string[], right: string[]) {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function sameTypeRecord(left: Record<string, WidgetInputType>, right: Record<string, WidgetInputType>) {
const leftEntries = Object.entries(left);
const rightEntries = Object.entries(right);
return leftEntries.length === rightEntries.length && leftEntries.every(([name, type]) => right[name] === type);
}
function sameInputRecord(left: Record<string, WidgetInputValue>, right: Record<string, WidgetInputValue>) {
const leftEntries = Object.entries(left);
const rightEntries = Object.entries(right);
return (
leftEntries.length === rightEntries.length && leftEntries.every(([name, value]) => Object.is(right[name], value))
);
}