diff --git a/packages/ui-next/src/widgets/utils/introspection.ts b/packages/ui-next/src/widgets/utils/introspection.ts new file mode 100644 index 00000000..f64dcb7c --- /dev/null +++ b/packages/ui-next/src/widgets/utils/introspection.ts @@ -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> { + const map: Partial> = {}; + + 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 + ); +} diff --git a/packages/ui-next/src/widgets/widgets/sample/ImageWidget.tsx b/packages/ui-next/src/widgets/widgets/sample/ImageWidget.tsx new file mode 100644 index 00000000..2b312efc --- /dev/null +++ b/packages/ui-next/src/widgets/widgets/sample/ImageWidget.tsx @@ -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 ( +
+ {alt +
+ ); +} diff --git a/packages/ui-next/src/widgets/widgets/sample/ImageWidgetEdit.tsx b/packages/ui-next/src/widgets/widgets/sample/ImageWidgetEdit.tsx new file mode 100644 index 00000000..c876184a --- /dev/null +++ b/packages/ui-next/src/widgets/widgets/sample/ImageWidgetEdit.tsx @@ -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(null); + + const commit = () => { + const nextUrl = inputRef.current?.value ?? url ?? ""; + onPropsChange({ url: nextUrl, alt, fit, height }); + }; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {alt + + {/* URL edit overlay — visible on hover */} +
+ + URL + + 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", + }} + /> +
+
+ ); +}