ui:next widgets 1/2
This commit is contained in:
parent
302e0ee5c1
commit
d09819254d
58
packages/ui-next/src/widgets/utils/introspection.ts
Normal file
58
packages/ui-next/src/widgets/utils/introspection.ts
Normal 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
|
||||
);
|
||||
}
|
||||
38
packages/ui-next/src/widgets/widgets/sample/ImageWidget.tsx
Normal file
38
packages/ui-next/src/widgets/widgets/sample/ImageWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user