ui:next widgets1/2

This commit is contained in:
lovebird 2026-04-09 19:07:01 +02:00
parent 0626be2cf6
commit 5f75ee5bb1
18 changed files with 1322 additions and 33 deletions

View File

@ -8,9 +8,13 @@
"name": "@polymech/ui-next",
"version": "0.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@preact/preset-vite": "^2.10.5",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-router": "^1.114.3",
"lucide-react": "^1.8.0",
"preact": "^10.29.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@ -322,6 +326,60 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@ -1961,6 +2019,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -2364,8 +2431,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",

View File

@ -12,9 +12,13 @@
"serve": "npx serve dist -s"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@preact/preset-vite": "^2.10.5",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-router": "^1.114.3",
"lucide-react": "^1.8.0",
"preact": "^10.29.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",

View File

@ -1,13 +1,17 @@
import { useEffect } from "react";
import { bootWidgetSystem } from "@/widgets/bootstrap/boot";
import { NodeRenderer } from "@/widgets/renderer/NodeRenderer";
import { useLayoutStore } from "@/store/useLayoutStore";
import type { LayoutNode, PageLayout } from "@/widgets/types";
import { EditorProvider, useEditorContext } from "@/widgets-editor/context/EditorContext";
import {
LazyEditToggle,
LazyEditableNodeRenderer,
LazyNodePropertiesPanel,
} from "@/widgets-editor/lazy";
// ─── Seed data ────────────────────────────────────────────────────────────────
// Mirrors the "FlexibleContainer + PhotoCards" example from widgets-api §14,
// adapted to use text-block widgets (available in the core plugin).
const PAGE_ID = "demo-flex-page";
@ -24,7 +28,7 @@ function makeSeedLayout(): PageLayout {
id: "r1",
type: "flex-row",
props: {},
children: [textBlock("w1", "Hero section", "Full-width content spanning the single column.")],
children: [textBlock("w1", "Hero section", "Full-width — single column row.")],
parentId: "root",
layout: { display: "grid", columns: 1, gap: 16 },
};
@ -34,9 +38,9 @@ function makeSeedLayout(): PageLayout {
type: "flex-row",
props: {},
children: [
textBlock("w2", "Card A", "Column 0 — flex-row with 3 equal columns."),
textBlock("w3", "Card B", "Column 1 — position = order in children[]."),
textBlock("w4", "Card C", "Column 2 — no rowId/column indices needed."),
textBlock("w2", "Card A", "Column 0"),
textBlock("w3", "Card B", "Column 1"),
textBlock("w4", "Card C", "Column 2"),
],
parentId: "root",
layout: { display: "grid", columns: 3, gap: 12 },
@ -61,10 +65,11 @@ function makeSeedLayout(): PageLayout {
};
}
// ─── Page component ───────────────────────────────────────────────────────────
// ─── Inner page (needs EditorContext in scope) ────────────────────────────────
export function FlexNodeDemoPage() {
function FlexNodeDemoInner() {
const { pages, initPage } = useLayoutStore();
const { isEditMode, selectedNodeId } = useEditorContext();
const page = pages[PAGE_ID];
useEffect(() => {
@ -77,19 +82,54 @@ export function FlexNodeDemoPage() {
return (
<section className="nested">
<h1>Flex node scaffold</h1>
<p className="muted">
Unified Node model (§14) <code>flex</code> + <code>flex-row</code> nodes rendered by{" "}
<code>NodeRenderer</code>. No <code>rowId</code>/column indices; position = order in{" "}
<code>children[]</code>.
</p>
{/* ── Toolbar ── */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "1rem" }}>
<div>
<h1 style={{ margin: 0 }}>Flex node scaffold</h1>
<p className="muted" style={{ margin: "0.25rem 0 0" }}>
Node model §14 <code>flex</code> + <code>flex-row</code> via{" "}
<code>EditableNodeRenderer</code>. Toggle edit to drag-reorder.
</p>
</div>
<LazyEditToggle />
</div>
{page ? (
<NodeRenderer node={page.root} pageId={PAGE_ID} />
) : (
<p className="muted">Bootstrapping</p>
{/* ── Edit mode indicator strip ── */}
{isEditMode && (
<div
style={{
padding: "0.375rem 0.75rem",
marginBottom: "0.75rem",
borderRadius: "0.375rem",
background: "#1e3a5f",
color: "#93c5fd",
fontSize: "0.8125rem",
display: "flex",
gap: "1rem",
}}
>
<span>Edit mode drag rows or cards to reorder</span>
{selectedNodeId && (
<span style={{ color: "#fbbf24" }}>
Selected: <code>{selectedNodeId}</code>
</span>
)}
</div>
)}
{/* ── Canvas + properties panel ── */}
<div style={{ display: "flex", gap: "1rem", alignItems: "flex-start" }}>
<div style={{ flex: 1, minWidth: 0, paddingTop: isEditMode ? "1.5rem" : 0 }}>
{page ? (
<LazyEditableNodeRenderer node={page.root} />
) : (
<p className="muted">Bootstrapping</p>
)}
</div>
<LazyNodePropertiesPanel />
</div>
{/* ── Raw tree inspector ── */}
<details className="panel" style={{ marginTop: "2rem" }}>
<summary style={{ cursor: "pointer" }}>
<strong>Node tree (raw)</strong>
@ -101,3 +141,13 @@ export function FlexNodeDemoPage() {
</section>
);
}
// ─── Page component (provides EditorContext) ──────────────────────────────────
export function FlexNodeDemoPage() {
return (
<EditorProvider pageId={PAGE_ID}>
<FlexNodeDemoInner />
</EditorProvider>
);
}

View File

@ -4,9 +4,9 @@ import type { LayoutNode, LayoutStoreAccessor, PageLayout } from "@/widgets/type
import type { PagePersistence } from "./persistence/PagePersistence";
import { LocalStoragePagePersistence } from "./persistence/LocalStoragePagePersistence";
// ─── Tree helpers ────────────────────────────────────────────────────────────
// ─── Tree helpers (exported for panel / utils) ───────────────────────────────
function findNode(root: LayoutNode, id: string): LayoutNode | null {
export function findNode(root: LayoutNode, id: string): LayoutNode | null {
if (root.id === id) return root;
for (const child of root.children) {
const found = findNode(child, id);
@ -61,6 +61,8 @@ export interface LayoutState {
moveNode(pageId: string, nodeId: string, newParentId: string, index?: number): void;
/** Shallow-merge props onto a node. */
updateNodeProps(pageId: string, nodeId: string, props: Partial<Record<string, unknown>>): void;
/** Shallow-merge layout hints onto a node. */
updateNodeLayout(pageId: string, nodeId: string, layout: Partial<import("@/widgets/types").NodeLayout>): void;
}
// ─── Factory ─────────────────────────────────────────────────────────────────
@ -154,6 +156,18 @@ function createStore(persistence: PagePersistence) {
return { pages: { ...s.pages, [pageId]: { ...page, root: newRoot, updatedAt: Date.now() } } };
});
},
updateNodeLayout(pageId, nodeId, layout) {
set((s) => {
const page = s.pages[pageId];
if (!page) return s;
const newRoot = mapNode(page.root, nodeId, (n) => ({
...n,
layout: { ...n.layout, ...layout } as typeof n.layout,
}));
return { pages: { ...s.pages, [pageId]: { ...page, root: newRoot, updatedAt: Date.now() } } };
});
},
}));
}

View File

@ -0,0 +1,172 @@
import { Suspense, useCallback } from "react";
import {
DndContext,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
closestCenter,
} from "@dnd-kit/core";
import type { DragEndEvent } from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
horizontalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import { widgetRegistry } from "@/widgets/system";
import { useLayoutStore } from "@/store/useLayoutStore";
import { NodeRenderer } from "@/widgets/renderer/NodeRenderer";
import type { LayoutNode } from "@/widgets/types";
import { useEditorContext } from "./context/EditorContext";
import { NodeEditorOverlay } from "./components/NodeEditorOverlay";
import { SortableItem } from "./components/SortableItem";
// ─── Per-container DnD wrapper ───────────────────────────────────────────────
interface SortableContainerProps {
node: LayoutNode;
strategy: "vertical" | "horizontal";
renderChild: (child: LayoutNode) => React.ReactNode;
}
function SortableContainer({ node, strategy, renderChild }: SortableContainerProps) {
const { pageId } = useEditorContext();
const { moveNode, pages } = useLayoutStore();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const childIds = node.children.map((c) => c.id);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const page = pages[pageId];
if (!page) return;
// Find current parent (this node) children
const oldIndex = childIds.indexOf(String(active.id));
const newIndex = childIds.indexOf(String(over.id));
if (oldIndex === -1 || newIndex === -1) return;
// Use arrayMove to get the new order, then apply via moveNode
const reordered = arrayMove(node.children, oldIndex, newIndex);
reordered.forEach((child, idx) => {
// Only move the one that changed; find the swapped pair
if (idx !== node.children.indexOf(child)) {
// eslint-disable-next-line no-console
}
});
// Simplest: move the dragged node to the target index
const draggedChild = node.children[oldIndex];
moveNode(pageId, draggedChild.id, node.id, newIndex);
};
const sortingStrategy =
strategy === "vertical" ? verticalListSortingStrategy : horizontalListSortingStrategy;
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={childIds} strategy={sortingStrategy}>
{node.children.map((child) => renderChild(child))}
</SortableContext>
</DndContext>
);
}
// ─── Main renderer ───────────────────────────────────────────────────────────
export interface EditableNodeRendererProps {
node: LayoutNode;
depth?: number;
pageId?: string;
}
export function EditableNodeRenderer({
node,
depth = 0,
}: EditableNodeRendererProps) {
const { isEditMode, pageId } = useEditorContext();
const removeNode = useLayoutStore((s) => s.removeNode);
const updateNodeProps = useLayoutStore((s) => s.updateNodeProps);
// Stable onPropsChange that writes back into the store
const handlePropsChange = useCallback(
async (partial: Record<string, unknown>) => {
updateNodeProps(pageId, node.id, partial);
},
[pageId, node.id, updateNodeProps],
);
// View mode — delegate entirely to the read-only renderer
if (!isEditMode) {
return <NodeRenderer node={node} depth={depth} pageId={pageId} />;
}
const def = widgetRegistry.get(node.type);
if (!def) return null;
const canNest = def.constraints?.canHaveChildren ?? false;
// Shared props passed to whichever component renders this node
const sharedProps = {
...(node.props as Record<string, unknown>),
nodeId: node.id,
widgetInstanceId: node.id,
widgetDefId: node.type,
layout: node.layout,
isEditMode: true,
onPropsChange: handlePropsChange,
} as const;
// ── Leaf widget with an editComponent: lazy-load the inline editor ──────────
if (!canNest && def.editComponent) {
const EditComp = def.editComponent;
// Fallback to view component while the lazy chunk is loading
const Fallback = def.component;
return (
<Suspense fallback={<Fallback {...sharedProps} />}>
<EditComp {...sharedProps} />
</Suspense>
);
}
// ── Container widget (flex, flex-row, …) ────────────────────────────────────
const Component = def.component;
const sortStrategy: "vertical" | "horizontal" =
node.type === "flex" ? "vertical" : "horizontal";
const renderChild = (child: LayoutNode) => (
<SortableItem key={child.id} id={child.id}>
{(dragHandleProps) => (
<NodeEditorOverlay
nodeId={child.id}
nodeType={child.type}
dragHandleProps={dragHandleProps}
onDelete={() => removeNode(pageId, child.id)}
>
<EditableNodeRenderer node={child} depth={depth + 1} />
</NodeEditorOverlay>
)}
</SortableItem>
);
const editChildren = canNest ? (
<SortableContainer node={node} strategy={sortStrategy} renderChild={renderChild} />
) : null;
return (
<Component {...sharedProps}>
{editChildren}
</Component>
);
}

View File

@ -0,0 +1,12 @@
/**
* EditorShell the single dynamic-import chunk boundary for all editor code.
*
* Everything reachable from this file (@dnd-kit, lucide, overlays, property
* panel) is bundled into one "editor" chunk. It is NEVER loaded in a pure
* view/preview context; only when the user first activates edit mode.
*
* Do not import this file statically. Use the lazy wrappers in ./lazy.tsx.
*/
export { EditableNodeRenderer } from "./EditableNodeRenderer";
export { NodePropertiesPanel } from "./components/NodePropertiesPanel";
export { EditToggle } from "./components/EditToggle";

View File

@ -0,0 +1,75 @@
import { Eye, Pencil, Save } from "lucide-react";
import { useEditorContext } from "@/widgets-editor/context/EditorContext";
import { useLayoutStore } from "@/store/useLayoutStore";
export function EditToggle() {
const { isEditMode, setEditMode, pageId } = useEditorContext();
const savePage = useLayoutStore((s) => s.savePage);
const handleSave = async () => {
await savePage(pageId);
};
return (
<div
style={{
display: "flex",
gap: "0.5rem",
alignItems: "center",
padding: "0.375rem 0.75rem",
background: "var(--editor-bar-bg, #1e293b)",
borderRadius: "0.5rem",
fontSize: "0.8125rem",
fontWeight: 500,
}}
>
<button
type="button"
onClick={() => setEditMode(!isEditMode)}
title={isEditMode ? "Switch to preview" : "Switch to edit mode"}
style={{
display: "flex",
alignItems: "center",
gap: "0.375rem",
padding: "0.25rem 0.625rem",
borderRadius: "0.375rem",
border: "none",
cursor: "pointer",
fontSize: "inherit",
fontWeight: "inherit",
background: isEditMode ? "#3b82f6" : "transparent",
color: isEditMode ? "#fff" : "#94a3b8",
transition: "background 0.15s, color 0.15s",
}}
>
{isEditMode ? <Pencil size={14} /> : <Eye size={14} />}
{isEditMode ? "Editing" : "Preview"}
</button>
{isEditMode && (
<button
type="button"
onClick={handleSave}
title="Save to localStorage"
style={{
display: "flex",
alignItems: "center",
gap: "0.375rem",
padding: "0.25rem 0.625rem",
borderRadius: "0.375rem",
border: "none",
cursor: "pointer",
fontSize: "inherit",
fontWeight: "inherit",
background: "transparent",
color: "#4ade80",
transition: "background 0.15s",
}}
>
<Save size={14} />
Save
</button>
)}
</div>
);
}

View File

@ -0,0 +1,150 @@
import type { ReactNode, SyntheticEvent, CSSProperties } from "react";
import { Trash2 } from "lucide-react";
import { useEditorContext } from "@/widgets-editor/context/EditorContext";
/** Colour-coded per node type category */
const TYPE_COLORS: Record<string, string> = {
flex: "#6366f1", // indigo — container
"flex-row": "#8b5cf6", // violet — row
"text-block": "#0ea5e9", // sky — content
};
function typeBadgeColor(type: string): string {
return TYPE_COLORS[type] ?? "#64748b";
}
export interface NodeEditorOverlayProps {
nodeId: string;
nodeType: string;
/** The visual node content */
children: ReactNode;
onDelete?: () => void;
/** Drag listeners/attributes from useSortable — injected into the grip handle */
dragHandleProps?: Record<string, unknown>;
/** Extra wrapper style (e.g. opacity while dragging) */
style?: CSSProperties;
}
export function NodeEditorOverlay({
nodeId,
nodeType,
children,
onDelete,
dragHandleProps,
style,
}: NodeEditorOverlayProps) {
const { selectedNodeId, setSelectedNodeId } = useEditorContext();
const isSelected = selectedNodeId === nodeId;
const handleSelect = (e: SyntheticEvent) => {
e.stopPropagation();
setSelectedNodeId(isSelected ? null : nodeId);
};
const badgeColor = typeBadgeColor(nodeType);
return (
<div
onClick={handleSelect}
style={{
position: "relative",
borderRadius: "0.375rem",
outline: isSelected
? `2px solid ${badgeColor}`
: "2px solid transparent",
outlineOffset: "2px",
transition: "outline-color 0.12s",
cursor: "default",
...style,
}}
>
{/* Floating header bar — visible on hover/selected */}
<div
className="editor-overlay-bar"
style={{
position: "absolute",
top: "-1.5rem",
left: 0,
display: "flex",
alignItems: "center",
gap: "0.25rem",
zIndex: 10,
opacity: isSelected ? 1 : 0,
transition: "opacity 0.12s",
pointerEvents: isSelected ? "auto" : "none",
}}
>
{/* Drag grip */}
{dragHandleProps && (
<span
{...dragHandleProps}
title="Drag to reorder"
style={{
cursor: "grab",
padding: "0.125rem 0.25rem",
borderRadius: "0.25rem",
background: badgeColor,
color: "#fff",
fontSize: "0.6875rem",
userSelect: "none",
lineHeight: 1,
}}
>
</span>
)}
{/* Type badge */}
<span
style={{
padding: "0.125rem 0.375rem",
borderRadius: "0.25rem",
background: badgeColor,
color: "#fff",
fontSize: "0.6875rem",
fontFamily: "monospace",
lineHeight: 1,
}}
>
{nodeType}
</span>
{/* Delete */}
{onDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
title="Delete node"
style={{
display: "flex",
alignItems: "center",
padding: "0.125rem",
borderRadius: "0.25rem",
border: "none",
background: "#ef4444",
color: "#fff",
cursor: "pointer",
lineHeight: 1,
}}
>
<Trash2 size={11} />
</button>
)}
</div>
{/* Hover glow when NOT selected */}
<style>{`
.editor-node-wrap:hover .editor-overlay-bar { opacity: 1 !important; pointer-events: auto !important; }
`}</style>
<div
className="editor-node-wrap"
style={{ position: "relative" }}
>
{children}
</div>
</div>
);
}

View File

@ -0,0 +1,266 @@
import { useEffect, useId, useState } from "react";
import type { ConfigField, ConfigSchema } from "@/widgets/types";
// ─── Shared primitive helpers ─────────────────────────────────────────────────
const label: React.CSSProperties = {
display: "block",
fontSize: "0.6875rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "#94a3b8",
marginBottom: "0.25rem",
};
const input: React.CSSProperties = {
width: "100%",
padding: "0.3rem 0.5rem",
fontSize: "0.8125rem",
borderRadius: "0.25rem",
border: "1px solid #334155",
background: "#0f172a",
color: "#e2e8f0",
outline: "none",
boxSizing: "border-box",
};
const description: React.CSSProperties = {
fontSize: "0.6875rem",
color: "#64748b",
marginTop: "0.2rem",
};
/** Text input that commits on blur/Enter, not on every keystroke. */
function CommitInput({
value,
onChange,
type = "text",
min,
max,
placeholder,
}: {
value: string;
onChange: (v: string) => void;
type?: string;
min?: number;
max?: number;
placeholder?: string;
}) {
const [local, setLocal] = useState(value);
useEffect(() => setLocal(value), [value]);
return (
<input
style={input}
type={type}
min={min}
max={max}
value={local}
placeholder={placeholder}
onChange={(e) => setLocal(e.target.value)}
onBlur={() => onChange(local)}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
/>
);
}
// ─── Field renderers ──────────────────────────────────────────────────────────
function FieldWrapper({
fieldKey,
config,
children,
}: {
fieldKey: string;
config: ConfigField;
children: React.ReactNode;
}) {
return (
<div style={{ marginBottom: "0.875rem" }} key={fieldKey}>
<span style={label}>{config.label}</span>
{children}
{config.description && <p style={description}>{config.description}</p>}
</div>
);
}
function renderField(
fieldKey: string,
config: ConfigField,
value: unknown,
onChange: (v: unknown) => void,
): React.ReactNode {
const id = `field-${fieldKey}`;
switch (config.type) {
case "text":
return (
<FieldWrapper key={fieldKey} fieldKey={fieldKey} config={config}>
<CommitInput
value={String(value ?? config.default ?? "")}
onChange={onChange}
placeholder={config.default != null ? String(config.default) : undefined}
/>
</FieldWrapper>
);
case "number":
return (
<FieldWrapper key={fieldKey} fieldKey={fieldKey} config={config}>
<CommitInput
type="number"
value={String(value ?? config.default ?? "")}
onChange={(v) => onChange(Number(v) || (config.default as number) || 0)}
min={config.min as number | undefined}
max={config.max as number | undefined}
/>
</FieldWrapper>
);
case "boolean":
return (
<FieldWrapper key={fieldKey} fieldKey={fieldKey} config={config}>
<label
htmlFor={id}
style={{ display: "flex", alignItems: "center", gap: "0.5rem", cursor: "pointer" }}
>
<input
id={id}
type="checkbox"
checked={Boolean(value ?? config.default)}
onChange={(e) => onChange(e.target.checked)}
style={{ width: 14, height: 14, accentColor: "#3b82f6" }}
/>
<span style={{ fontSize: "0.8125rem", color: "#cbd5e1" }}>
{value ? "Enabled" : "Disabled"}
</span>
</label>
</FieldWrapper>
);
case "select": {
const options = (config.options as Array<{ value: string; label: string }>) ?? [];
return (
<FieldWrapper key={fieldKey} fieldKey={fieldKey} config={config}>
<select
id={id}
value={String(value ?? config.default ?? "")}
onChange={(e) => onChange(e.target.value)}
style={{ ...input, cursor: "pointer" }}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</FieldWrapper>
);
}
case "markdown":
return (
<FieldWrapper key={fieldKey} fieldKey={fieldKey} config={config}>
<textarea
id={id}
value={String(value ?? config.default ?? "")}
rows={4}
onChange={(e) => onChange(e.target.value)}
placeholder={config.default != null ? String(config.default) : undefined}
style={{
...input,
fontFamily: "monospace",
resize: "vertical",
minHeight: "5rem",
}}
/>
</FieldWrapper>
);
case "color":
return (
<FieldWrapper key={fieldKey} fieldKey={fieldKey} config={config}>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<input
type="color"
defaultValue={String(value ?? config.default ?? "#000000")}
key={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
style={{ width: 32, height: 28, padding: 1, borderRadius: 4, border: "1px solid #334155", background: "transparent", cursor: "pointer" }}
/>
<CommitInput
value={String(value ?? config.default ?? "#000000")}
onChange={onChange}
placeholder="#000000"
/>
</div>
</FieldWrapper>
);
default:
return null;
}
}
// ─── Group renderer ───────────────────────────────────────────────────────────
function groupFields(schema: ConfigSchema<Record<string, unknown>>) {
const grouped: Record<string, [string, ConfigField][]> = {};
for (const [key, cfg] of Object.entries(schema)) {
if (!cfg) continue;
const group = (cfg.group as string) ?? "General";
(grouped[group] ??= []).push([key, cfg as ConfigField]);
}
return grouped;
}
// ─── Public component ─────────────────────────────────────────────────────────
export interface NodePropertiesFormProps {
/** The configSchema from the widget definition. */
schema: ConfigSchema<Record<string, unknown>>;
/** Current node.props (the values to display). */
currentProps: Record<string, unknown>;
/** Called with a full shallow-merged props object on any field change. */
onPropsChange: (next: Record<string, unknown>) => void;
}
export function NodePropertiesForm({
schema,
currentProps,
onPropsChange,
}: NodePropertiesFormProps) {
const grouped = groupFields(schema);
const update = (key: string, value: unknown) => {
onPropsChange({ ...currentProps, [key]: value });
};
return (
<div>
{Object.entries(grouped).map(([group, fields]) => (
<section key={group} style={{ marginBottom: "1.25rem" }}>
<div
style={{
fontSize: "0.625rem",
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "#475569",
borderBottom: "1px solid #1e293b",
paddingBottom: "0.25rem",
marginBottom: "0.75rem",
}}
>
{group}
</div>
{fields.map(([key, cfg]) =>
renderField(key, cfg, currentProps[key], (v) => update(key, v)),
)}
</section>
))}
</div>
);
}

View File

@ -0,0 +1,155 @@
import { X } from "lucide-react";
import { widgetRegistry } from "@/widgets/system";
import { useLayoutStore, findNode } from "@/store/useLayoutStore";
import { useEditorContext } from "@/widgets-editor/context/EditorContext";
import { NodePropertiesForm } from "./NodePropertiesForm";
const TYPE_COLORS: Record<string, string> = {
flex: "#6366f1",
"flex-row": "#8b5cf6",
"text-block": "#0ea5e9",
};
function badgeColor(type: string) {
return TYPE_COLORS[type] ?? "#64748b";
}
export function NodePropertiesPanel() {
const { selectedNodeId, setSelectedNodeId, pageId } = useEditorContext();
const pages = useLayoutStore((s) => s.pages);
const updateNodeProps = useLayoutStore((s) => s.updateNodeProps);
if (!selectedNodeId) return null;
const page = pages[pageId];
if (!page) return null;
const node = findNode(page.root, selectedNodeId);
if (!node) return null;
const def = widgetRegistry.get(node.type);
const schema = def?.metadata.configSchema;
return (
<aside
style={{
width: 260,
flexShrink: 0,
display: "flex",
flexDirection: "column",
background: "#0f172a",
borderRadius: "0.5rem",
border: "1px solid #1e293b",
overflow: "hidden",
fontSize: "0.8125rem",
color: "#e2e8f0",
alignSelf: "flex-start",
position: "sticky",
top: "1rem",
maxHeight: "calc(100vh - 6rem)",
}}
>
{/* ── Header ── */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.625rem 0.75rem",
borderBottom: "1px solid #1e293b",
background: "#0a1220",
}}
>
<span
style={{
padding: "0.1rem 0.4rem",
borderRadius: "0.25rem",
background: badgeColor(node.type),
color: "#fff",
fontSize: "0.6875rem",
fontFamily: "monospace",
fontWeight: 700,
lineHeight: 1.4,
}}
>
{node.type}
</span>
<span
style={{
flex: 1,
fontSize: "0.6875rem",
color: "#64748b",
fontFamily: "monospace",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={node.id}
>
{node.id}
</span>
<button
type="button"
onClick={() => setSelectedNodeId(null)}
title="Close panel"
style={{
display: "flex",
alignItems: "center",
padding: "0.125rem",
border: "none",
background: "transparent",
color: "#64748b",
cursor: "pointer",
borderRadius: "0.25rem",
}}
>
<X size={14} />
</button>
</div>
{/* ── Body ── */}
<div style={{ flex: 1, overflowY: "auto", padding: "0.75rem" }}>
{schema ? (
<NodePropertiesForm
schema={schema}
currentProps={node.props}
onPropsChange={(next) => updateNodeProps(pageId, node.id, next)}
/>
) : (
<p style={{ color: "#475569", fontSize: "0.75rem", textAlign: "center", padding: "1.5rem 0" }}>
No configurable properties
</p>
)}
{/* ── Node info (debug) ── */}
<details style={{ marginTop: "1rem" }}>
<summary
style={{
cursor: "pointer",
fontSize: "0.6875rem",
color: "#475569",
userSelect: "none",
}}
>
Raw node
</summary>
<pre
style={{
marginTop: "0.5rem",
fontSize: "0.625rem",
color: "#475569",
overflow: "auto",
maxHeight: 180,
background: "#0a1220",
borderRadius: "0.25rem",
padding: "0.5rem",
}}
>
{JSON.stringify(node, null, 2)}
</pre>
</details>
</div>
</aside>
);
}

View File

@ -0,0 +1,39 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { ReactNode } from "react";
interface SortableItemProps {
id: string;
children: (dragHandleProps: Record<string, unknown>) => ReactNode;
}
/**
* Generic sortable wrapper.
* Passes drag-handle listeners/attributes to children via render-prop so the
* overlay can place the grip wherever it wants.
*/
export function SortableItem({ id, children }: SortableItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.35 : 1,
zIndex: isDragging ? 999 : undefined,
position: "relative",
}}
>
{children({ ...listeners, ...attributes })}
</div>
);
}

View File

@ -0,0 +1,38 @@
import { createContext, useContext, useState } from "react";
import type { ReactNode } from "react";
export interface EditorContextValue {
isEditMode: boolean;
setEditMode: (v: boolean) => void;
selectedNodeId: string | null;
setSelectedNodeId: (id: string | null) => void;
/** Page being edited — used by tree ops */
pageId: string;
}
const EditorContext = createContext<EditorContextValue | null>(null);
export function EditorProvider({
pageId,
children,
}: {
pageId: string;
children: ReactNode;
}) {
const [isEditMode, setEditMode] = useState(false);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
return (
<EditorContext.Provider
value={{ isEditMode, setEditMode, selectedNodeId, setSelectedNodeId, pageId }}
>
{children}
</EditorContext.Provider>
);
}
export function useEditorContext(): EditorContextValue {
const ctx = useContext(EditorContext);
if (!ctx) throw new Error("useEditorContext must be used inside <EditorProvider>");
return ctx;
}

View File

@ -0,0 +1,16 @@
export { EditorProvider, useEditorContext } from "./context/EditorContext";
export type { EditorContextValue } from "./context/EditorContext";
export { EditToggle } from "./components/EditToggle";
export { NodeEditorOverlay } from "./components/NodeEditorOverlay";
export type { NodeEditorOverlayProps } from "./components/NodeEditorOverlay";
export { SortableItem } from "./components/SortableItem";
export { NodePropertiesForm } from "./components/NodePropertiesForm";
export type { NodePropertiesFormProps } from "./components/NodePropertiesForm";
export { NodePropertiesPanel } from "./components/NodePropertiesPanel";
export { EditableNodeRenderer } from "./EditableNodeRenderer";
export type { EditableNodeRendererProps } from "./EditableNodeRenderer";
// ─── Lazy wrappers (main-bundle safe — no heavy deps) ────────────────────────
export { LazyEditableNodeRenderer, LazyNodePropertiesPanel, LazyEditToggle } from "./lazy";

View File

@ -0,0 +1,101 @@
/**
* Lazy wrappers for the editor shell.
*
* These thin components live in the MAIN bundle (no heavy imports). They each
* hold a `React.lazy` reference into the EditorShell chunk, which loads only
* once the user first enters edit mode.
*
* All three use the same `import("./EditorShell")` promise so the browser
* issues a single network request and Vite emits a single "editor" chunk.
*/
import React, { Suspense } from "react";
import { NodeRenderer } from "@/widgets/renderer/NodeRenderer";
import { useEditorContext } from "./context/EditorContext";
import type { LayoutNode } from "@/widgets/types";
// ─── Lazy references (all point at the same chunk) ────────────────────────────
const _EditableNodeRenderer = React.lazy(() =>
import("./EditorShell").then((m) => ({ default: m.EditableNodeRenderer })),
);
const _NodePropertiesPanel = React.lazy(() =>
import("./EditorShell").then((m) => ({ default: m.NodePropertiesPanel })),
);
const _EditToggle = React.lazy(() =>
import("./EditorShell").then((m) => ({ default: m.EditToggle })),
);
// ─── Public wrappers ──────────────────────────────────────────────────────────
/**
* Canvas renderer.
* - View mode renders NodeRenderer immediately (no lazy penalty).
* - Edit mode lazy-loads EditableNodeRenderer (DnD + overlays chunk).
*/
export function LazyEditableNodeRenderer({
node,
depth = 0,
}: {
node: LayoutNode;
depth?: number;
}) {
const { isEditMode, pageId } = useEditorContext();
if (!isEditMode) {
return <NodeRenderer node={node} depth={depth} pageId={pageId} />;
}
return (
<Suspense fallback={<NodeRenderer node={node} depth={depth} pageId={pageId} />}>
<_EditableNodeRenderer node={node} depth={depth} />
</Suspense>
);
}
/**
* Properties panel renders nothing until a node is selected in edit mode.
* The chunk loads the first time a node is selected.
*/
export function LazyNodePropertiesPanel() {
const { isEditMode, selectedNodeId } = useEditorContext();
if (!isEditMode || !selectedNodeId) return null;
return (
<Suspense fallback={null}>
<_NodePropertiesPanel />
</Suspense>
);
}
/**
* Edit / preview toggle bar.
* Shows a minimal placeholder while the editor chunk is loading.
*/
export function LazyEditToggle() {
return (
<Suspense
fallback={
<button
type="button"
disabled
style={{
padding: "0.375rem 0.875rem",
borderRadius: "0.375rem",
border: "1px solid #334155",
background: "#1e293b",
color: "#64748b",
fontSize: "0.8125rem",
cursor: "not-allowed",
}}
>
Edit
</button>
}
>
<_EditToggle />
</Suspense>
);
}

View File

@ -1,3 +1,4 @@
import React from "react";
import type { WidgetPlugin } from "@/widgets/types";
import { TextBlockWidget } from "@/widgets/widgets/sample/TextBlockWidget";
@ -8,6 +9,16 @@ import type { FlexNodeProps } from "@/widgets/nodes/flex/FlexNode";
import { FlexRowNode } from "@/widgets/nodes/flex/FlexRowNode";
import type { FlexRowNodeProps } from "@/widgets/nodes/flex/FlexRowNode";
/**
* Lazy-loaded edit component for text-block.
* Code-split: never loaded in view/preview mode, only when the editor is active.
*/
const TextBlockWidgetEdit = React.lazy(() =>
import("@/widgets/widgets/sample/TextBlockWidgetEdit").then((m) => ({
default: m.TextBlockWidgetEdit,
})),
);
/**
* Registers baseline widgets and layout nodes.
* Mirrors `registerWidgets()` as a plugin (widgets-api §13).
@ -29,6 +40,12 @@ export const coreWidgetsPlugin: WidgetPlugin = {
description: "Vertical flex column; children are flex-row nodes.",
tags: ["layout", "core"],
defaultProps: { gap: 16 },
configSchema: {
title: { type: "text", label: "Title", group: "Display", description: "Section heading" },
showTitle: { type: "boolean", label: "Show title", group: "Display" },
gap: { type: "number", label: "Row gap", group: "Layout", default: 16, min: 0, max: 128 },
enabled: { type: "boolean", label: "Enabled", group: "Display" },
},
},
constraints: {
canHaveChildren: true,
@ -44,8 +61,12 @@ export const coreWidgetsPlugin: WidgetPlugin = {
id: "flex-row",
name: "Flex row",
category: "layout",
description: "CSS grid row; columns controlled by node.layout.columns.",
description: "CSS grid row; columns controlled by columns prop.",
tags: ["layout", "core"],
configSchema: {
columns: { type: "number", label: "Columns", group: "Layout", default: 1, min: 1, max: 12 },
gap: { type: "number", label: "Gap", group: "Layout", default: 16, min: 0, max: 128 },
},
},
constraints: {
canHaveChildren: true,
@ -58,6 +79,7 @@ export const coreWidgetsPlugin: WidgetPlugin = {
api.registerWidget<TextBlockWidgetProps>({
component: TextBlockWidget,
editComponent: TextBlockWidgetEdit,
metadata: {
id: "text-block",
name: "Text block",

View File

@ -27,7 +27,8 @@ export function FlexNode({
}: FlexNodeProps) {
if (!enabled) return null;
const resolvedGap = layout?.gap ?? gap ?? 16;
// props.gap takes priority over layout.gap (props are edited at runtime; layout is the seed)
const resolvedGap = gap ?? layout?.gap ?? 16;
const inner = (
<div className="relative min-w-0 flex flex-col" style={{ gap: resolvedGap }}>

View File

@ -1,21 +1,24 @@
import type { BaseWidgetProps } from "@/widgets/types";
export interface FlexRowNodeProps extends BaseWidgetProps {
// All layout config lives in node.layout — no extra props needed.
/** Column count — editable via property panel, falls back to node.layout.columns. */
columns?: number;
/** Gap in px — editable via property panel, falls back to node.layout.gap. */
gap?: number;
}
/**
* Flex-row node renders children as a CSS grid row.
*
* `layout.columns` gridTemplateColumns (equal fr units)
* `layout.gap` column + row gap
* `columns` prop (or `layout.columns`) gridTemplateColumns (equal fr units)
* `gap` prop (or `layout.gap`) column + row gap
*
* On narrow viewports (`max-md`) the grid collapses to a single column,
* matching the behaviour of the legacy FlexContainerView.
* Props take priority over layout so the property panel can override the seed.
* On narrow viewports (`max-md`) the grid collapses to a single column.
*/
export function FlexRowNode({ layout, children }: FlexRowNodeProps) {
const columns = layout?.columns ?? 1;
const gap = layout?.gap ?? 16;
export function FlexRowNode({ layout, columns: propColumns, gap: propGap, children }: FlexRowNodeProps) {
const columns = propColumns ?? layout?.columns ?? 1;
const gap = propGap ?? layout?.gap ?? 16;
return (
<div

View File

@ -0,0 +1,105 @@
/**
* Inline edit component for `text-block`.
*
* Lazy-loaded via editComponent in corePlugin never bundled into the view path.
* Renders the same card chrome as TextBlockWidget but with contentEditable fields
* so the user can click-to-edit directly on the canvas.
*/
import { useEffect, useRef, useState } from "react";
import type { TextBlockWidgetProps } from "./TextBlockWidget";
export function TextBlockWidgetEdit({
title,
body,
onPropsChange,
}: TextBlockWidgetProps) {
const titleRef = useRef<HTMLHeadingElement>(null);
const bodyRef = useRef<HTMLParagraphElement>(null);
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const active = hovered || focused;
// Sync external prop changes into the DOM (e.g. properties panel update)
useEffect(() => {
if (titleRef.current && document.activeElement !== titleRef.current) {
titleRef.current.textContent = title ?? "";
}
}, [title]);
useEffect(() => {
if (bodyRef.current && document.activeElement !== bodyRef.current) {
bodyRef.current.textContent = body ?? "";
}
}, [body]);
const commit = () => {
const nextTitle = titleRef.current?.textContent ?? "";
const nextBody = bodyRef.current?.textContent ?? "";
onPropsChange({ title: nextTitle, body: nextBody });
};
return (
<div
className="panel widget-text-block"
style={{
position: "relative",
outline: active ? "2px dashed #3b82f6" : "2px dashed transparent",
outlineOffset: 2,
transition: "outline-color 0.15s ease",
cursor: "default",
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<h3
ref={titleRef}
contentEditable
suppressContentEditableWarning
onFocus={() => setFocused(true)}
onBlur={() => { setFocused(false); commit(); }}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
bodyRef.current?.focus();
}
}}
style={{ outline: "none", cursor: "text", minHeight: "1em" }}
>
{title ?? ""}
</h3>
<p
ref={bodyRef}
contentEditable
suppressContentEditableWarning
onFocus={() => setFocused(true)}
onBlur={() => { setFocused(false); commit(); }}
style={{
outline: "none",
cursor: "text",
minHeight: "1em",
color: body ? undefined : "#94a3b8",
}}
>
{body ?? ""}
</p>
<span
style={{
position: "absolute",
bottom: 4,
right: 6,
fontSize: "0.6rem",
color: "#3b82f6",
fontFamily: "monospace",
pointerEvents: "none",
opacity: active ? 0.6 : 0,
transition: "opacity 0.15s ease",
}}
>
click to edit
</span>
</div>
);
}