ui:next widgets 1/2

This commit is contained in:
lovebird 2026-04-09 20:21:37 +02:00
parent 302e0ee5c1
commit d09819254d
3 changed files with 186 additions and 0 deletions

View File

@ -0,0 +1,58 @@
import type { LayoutNode, WidgetCapability } from "@/widgets/types";
import { widgetRegistry } from "@/widgets/system";
// ─── Tree introspection helpers ──────────────────────────────────────────────
/**
* Walk the node tree depth-first and return every node whose registered widget
* declares the given capability.
*/
export function findNodesByCapability(
root: LayoutNode,
capability: WidgetCapability,
): LayoutNode[] {
const results: LayoutNode[] = [];
function walk(node: LayoutNode) {
const def = widgetRegistry.get(node.type);
if (def?.metadata.capabilities?.includes(capability)) {
results.push(node);
}
node.children.forEach(walk);
}
walk(root);
return results;
}
/**
* Returns a map of capability matching nodes for the entire subtree.
* Only lists capabilities that actually appear in the tree.
*/
export function treeCapabilityMap(
root: LayoutNode,
): Partial<Record<WidgetCapability, LayoutNode[]>> {
const map: Partial<Record<WidgetCapability, LayoutNode[]>> = {};
function walk(node: LayoutNode) {
const def = widgetRegistry.get(node.type);
for (const cap of def?.metadata.capabilities ?? []) {
(map[cap] ??= []).push(node);
}
node.children.forEach(walk);
}
walk(root);
return map;
}
/** True if a node's widget declares the given capability. */
export function nodeHasCapability(
node: LayoutNode,
capability: WidgetCapability,
): boolean {
return (
widgetRegistry.get(node.type)?.metadata.capabilities?.includes(capability) ??
false
);
}

View File

@ -0,0 +1,38 @@
import type { BaseWidgetProps } from "@/widgets/types";
export type ImageFit = "cover" | "contain" | "fill" | "none";
export interface ImageWidgetProps extends BaseWidgetProps {
/** Image source URL. Falls back to a random placeholder from picsum.photos. */
url?: string;
alt?: string;
/** CSS object-fit. Default: "cover". */
fit?: ImageFit;
/** Fixed height in px. Default: 200. */
height?: number;
}
const PLACEHOLDER = "https://picsum.photos/seed/polymech/800/400";
export function ImageWidget({
url,
alt,
fit = "cover",
height = 200,
enabled = true,
}: ImageWidgetProps) {
if (!enabled) return null;
return (
<div
className="panel widget-image"
style={{ padding: 0, overflow: "hidden" }}
>
<img
src={url || PLACEHOLDER}
alt={alt ?? ""}
style={{ width: "100%", height, objectFit: fit, display: "block" }}
/>
</div>
);
}

View File

@ -0,0 +1,90 @@
/**
* Inline edit component for the `image` widget.
*
* Lazy-loaded via editComponent in corePlugin never bundled into the view path.
* Overlays an editable URL input when the image is hovered/focused so the user
* can swap the image directly on the canvas.
*/
import { useRef, useState } from "react";
import type { ImageWidgetProps } from "./ImageWidget";
const PLACEHOLDER = "https://picsum.photos/seed/polymech/800/400";
export function ImageWidgetEdit({
url,
alt,
fit = "cover",
height = 200,
onPropsChange,
}: ImageWidgetProps) {
const [hovered, setHovered] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const commit = () => {
const nextUrl = inputRef.current?.value ?? url ?? "";
onPropsChange({ url: nextUrl, alt, fit, height });
};
return (
<div
className="panel widget-image"
style={{
padding: 0,
overflow: "hidden",
position: "relative",
outline: hovered ? "2px dashed #3b82f6" : "2px dashed transparent",
outlineOffset: 2,
transition: "outline-color 0.15s ease",
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<img
src={url || PLACEHOLDER}
alt={alt ?? ""}
style={{ width: "100%", height, objectFit: fit, display: "block" }}
/>
{/* URL edit overlay — visible on hover */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
background: "rgba(15,23,42,0.85)",
padding: "0.4rem 0.5rem",
display: "flex",
gap: "0.4rem",
alignItems: "center",
opacity: hovered ? 1 : 0,
transition: "opacity 0.15s ease",
pointerEvents: hovered ? "auto" : "none",
}}
>
<span
style={{ fontSize: "0.65rem", color: "#94a3b8", whiteSpace: "nowrap" }}
>
URL
</span>
<input
ref={inputRef}
defaultValue={url ?? ""}
placeholder="https://…"
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
style={{
flex: 1,
fontSize: "0.75rem",
padding: "0.2rem 0.4rem",
borderRadius: "0.25rem",
border: "1px solid #334155",
background: "#0f172a",
color: "#e2e8f0",
outline: "none",
}}
/>
</div>
</div>
);
}