ai-chatbot/components/canvas/ui/UiScreenRenderer.tsx
Your Name 3101f5966d
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: enhance canvas agent UI customization
2026-04-23 13:25:05 +08:00

363 lines
11 KiB
TypeScript

"use client";
import type { CSSProperties, PointerEvent } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowRight, CheckCircle2, ShieldCheck, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import type {
UiScreenAsset,
UiScreenDocument,
UiScreenLayer,
UiScreenLayerStyle,
UiScreenTokens,
} from "@/lib/ui-screen/schema";
interface UiScreenRendererProps {
document: UiScreenDocument;
className?: string;
interactive?: boolean;
selectedLayerId?: string | null;
onSelectLayer?: (layerId: string) => void;
}
const TONE_CLASS_MAP = {
primary: "border-blue-200 bg-blue-50 text-blue-700",
secondary: "border-violet-200 bg-violet-50 text-violet-700",
neutral: "border-slate-200 bg-slate-50 text-slate-700",
success: "border-emerald-200 bg-emerald-50 text-emerald-700",
danger: "border-rose-200 bg-rose-50 text-rose-700",
} as const;
function resolveTokenValue(
value: string | undefined,
tokenGroup: Record<string, string | number> | undefined
) {
if (!value) return undefined;
if (!tokenGroup) return value;
const tokenName = value.startsWith("token:") ? value.slice("token:".length) : value;
const tokenValue = tokenGroup[tokenName];
return tokenValue === undefined ? value : tokenValue;
}
function toFlexAlign(value: string | undefined) {
if (value === "center") return "center";
if (value === "end") return "flex-end";
if (value === "stretch") return "stretch";
return "flex-start";
}
function toFlexJustify(value: string | undefined) {
if (value === "center") return "center";
if (value === "end") return "flex-end";
if (value === "between") return "space-between";
return "flex-start";
}
function iconFor(name: string | undefined, size = 14) {
const style = { width: size, height: size };
if (name === "shield") return <ShieldCheck style={style} />;
if (name === "arrow") return <ArrowRight style={style} />;
if (name === "check") return <CheckCircle2 style={style} />;
return <Sparkles style={style} />;
}
function getStyle(
layer: UiScreenLayer,
tokens: UiScreenTokens | undefined,
assetMap: Map<string, UiScreenAsset>
): CSSProperties {
const layerStyle = layer.style || {};
const style: CSSProperties = {
opacity: layer.opacity,
};
if (layer.bounds) {
style.position = "absolute";
style.left = layer.bounds.x;
style.top = layer.bounds.y;
style.width = layer.bounds.width;
style.height = layer.bounds.height;
} else {
style.position = "relative";
}
const background = resolveTokenValue(layerStyle.background, tokens?.colors);
const color = resolveTokenValue(layerStyle.color, tokens?.colors);
const borderColor = resolveTokenValue(layerStyle.borderColor, tokens?.colors);
const boxShadow = resolveTokenValue(layerStyle.boxShadow, tokens?.shadows);
if (background) style.background = String(background);
if (color) style.color = String(color);
if (borderColor) style.borderColor = String(borderColor);
if (boxShadow) style.boxShadow = String(boxShadow);
if (typeof layerStyle.borderWidth === "number" && layerStyle.borderWidth > 0) {
style.borderWidth = layerStyle.borderWidth;
style.borderStyle = "solid";
}
if (typeof layerStyle.borderRadius === "number") style.borderRadius = layerStyle.borderRadius;
if (typeof layerStyle.padding === "number") style.padding = layerStyle.padding;
if (typeof layerStyle.paddingX === "number") {
style.paddingLeft = layerStyle.paddingX;
style.paddingRight = layerStyle.paddingX;
}
if (typeof layerStyle.paddingY === "number") {
style.paddingTop = layerStyle.paddingY;
style.paddingBottom = layerStyle.paddingY;
}
if (typeof layerStyle.blur === "number" && layerStyle.blur > 0) {
style.backdropFilter = `blur(${layerStyle.blur}px)`;
style.WebkitBackdropFilter = `blur(${layerStyle.blur}px)`;
}
applyTextStyle(style, layerStyle);
if (layer.kind === "stack") {
style.display = "flex";
style.flexDirection = layer.direction || "column";
style.alignItems = toFlexAlign(layer.align);
style.justifyContent = toFlexJustify(layer.justify);
style.gap = layerStyle.gap ?? 14;
}
if (layer.kind === "grid") {
style.display = "grid";
style.gridTemplateColumns = `repeat(${Math.max(1, layer.columns || 2)}, minmax(0, 1fr))`;
style.gap = layerStyle.gap ?? 14;
}
if (layer.kind === "card" || layer.kind === "frame") {
style.display = "flex";
style.flexDirection = layer.direction || "column";
style.alignItems = toFlexAlign(layer.align);
style.justifyContent = toFlexJustify(layer.justify);
style.gap = layerStyle.gap ?? 12;
}
if (layer.kind === "image") {
const asset = layer.assetId ? assetMap.get(layer.assetId) : null;
const src = layer.src || asset?.src;
if (!src) {
style.background =
style.background || "linear-gradient(135deg, rgba(226,232,240,0.72), rgba(248,250,252,0.95))";
}
}
return style;
}
function applyTextStyle(style: CSSProperties, layerStyle: UiScreenLayerStyle) {
if (layerStyle.fontFamily) style.fontFamily = layerStyle.fontFamily;
if (typeof layerStyle.fontSize === "number") style.fontSize = layerStyle.fontSize;
if (layerStyle.fontWeight !== undefined) style.fontWeight = layerStyle.fontWeight;
if (typeof layerStyle.lineHeight === "number") style.lineHeight = layerStyle.lineHeight;
if (typeof layerStyle.letterSpacing === "number") style.letterSpacing = layerStyle.letterSpacing;
if (layerStyle.textAlign) style.textAlign = layerStyle.textAlign;
if (layerStyle.textTransform) style.textTransform = layerStyle.textTransform;
}
function renderLayer(params: {
layer: UiScreenLayer;
tokens?: UiScreenTokens;
assetMap: Map<string, UiScreenAsset>;
interactive: boolean;
selectedLayerId?: string | null;
onSelectLayer?: (layerId: string) => void;
}) {
const { layer, tokens, assetMap, interactive, selectedLayerId, onSelectLayer } = params;
if (layer.visible === false) return null;
const isSelected = selectedLayerId === layer.id;
const baseStyle = getStyle(layer, tokens, assetMap);
const children = layer.children?.map((child) =>
renderLayer({
layer: child,
tokens,
assetMap,
interactive,
selectedLayerId,
onSelectLayer,
})
);
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (!interactive) return;
event.preventDefault();
event.stopPropagation();
onSelectLayer?.(layer.id);
};
const commonProps = {
key: layer.id,
"data-ui-layer-id": layer.id,
"data-canvas-no-drag": interactive ? "true" : undefined,
onPointerDown: handlePointerDown,
};
if (layer.kind === "image") {
const asset = layer.assetId ? assetMap.get(layer.assetId) : null;
const src = layer.src || asset?.src;
return (
<div
{...commonProps}
className={cn("overflow-hidden", isSelected && "ring-2 ring-blue-500 ring-offset-2")}
style={baseStyle}
>
{src ? (
<img
alt={layer.alt || asset?.alt || layer.name || ""}
className="h-full w-full"
draggable={false}
src={src}
style={{
objectFit:
layer.fit === "contain" ? "contain" : layer.fit === "fill" ? "fill" : "cover",
}}
/>
) : null}
</div>
);
}
if (layer.kind === "text") {
return (
<div
{...commonProps}
className={cn(
"whitespace-pre-wrap break-words",
isSelected && "rounded-[6px] ring-2 ring-blue-500 ring-offset-2"
)}
style={baseStyle}
>
{layer.text}
</div>
);
}
if (layer.kind === "button") {
return (
<div
{...commonProps}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-full border font-bold",
TONE_CLASS_MAP[layer.tone || "primary"],
isSelected && "ring-2 ring-blue-500 ring-offset-2"
)}
style={baseStyle}
>
<span>{layer.label || layer.text}</span>
{iconFor("arrow", 16)}
</div>
);
}
if (layer.kind === "badge") {
return (
<div
{...commonProps}
className={cn(
"inline-flex w-fit items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-bold",
TONE_CLASS_MAP[layer.tone || "neutral"],
isSelected && "ring-2 ring-blue-500 ring-offset-2"
)}
style={baseStyle}
>
{iconFor("sparkle", 13)}
<span>{layer.label || layer.text}</span>
</div>
);
}
if (layer.kind === "divider") {
return (
<div
{...commonProps}
className={cn("bg-slate-200", isSelected && "ring-2 ring-blue-500 ring-offset-2")}
style={baseStyle}
/>
);
}
return (
<div
{...commonProps}
className={cn(
"overflow-hidden",
layer.kind === "card" && "rounded-2xl border border-slate-200 bg-white shadow-sm",
isSelected && "ring-2 ring-blue-500 ring-offset-2"
)}
style={baseStyle}
>
{children}
</div>
);
}
export function UiScreenRenderer({
document,
className,
interactive = false,
selectedLayerId,
onSelectLayer,
}: UiScreenRendererProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ width: document.width, height: document.height });
useEffect(() => {
const element = containerRef.current;
if (!element || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
setSize({
width: Math.max(1, entry.contentRect.width),
height: Math.max(1, entry.contentRect.height),
});
});
observer.observe(element);
return () => observer.disconnect();
}, []);
const assetMap = useMemo(() => {
const map = new Map<string, UiScreenAsset>();
for (const asset of document.assets || []) {
map.set(asset.id, asset);
}
return map;
}, [document.assets]);
const scale = Math.min(size.width / document.width, size.height / document.height);
const offsetX = (size.width - document.width * scale) / 2;
const offsetY = (size.height - document.height * scale) / 2;
return (
<div
className={cn("relative h-full w-full overflow-hidden bg-white text-slate-950", className)}
ref={containerRef}
>
<div
className="absolute left-0 top-0 overflow-hidden"
style={{
width: document.width,
height: document.height,
transform: `translate(${offsetX}px, ${offsetY}px) scale(${scale})`,
transformOrigin: "top left",
}}
>
{document.layers.map((layer) =>
renderLayer({
layer,
tokens: document.tokens,
assetMap,
interactive,
selectedLayerId,
onSelectLayer,
})
)}
</div>
</div>
);
}