diff --git a/packages/ui-next/package-lock.json b/packages/ui-next/package-lock.json index cc4a4f44..f108f3ca 100644 --- a/packages/ui-next/package-lock.json +++ b/packages/ui-next/package-lock.json @@ -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", diff --git a/packages/ui-next/package.json b/packages/ui-next/package.json index 42a1e40d..8c760255 100644 --- a/packages/ui-next/package.json +++ b/packages/ui-next/package.json @@ -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", diff --git a/packages/ui-next/src/examples/widgets/FlexNodeDemoPage.tsx b/packages/ui-next/src/examples/widgets/FlexNodeDemoPage.tsx index 1b4bd0b9..f7151275 100644 --- a/packages/ui-next/src/examples/widgets/FlexNodeDemoPage.tsx +++ b/packages/ui-next/src/examples/widgets/FlexNodeDemoPage.tsx @@ -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 (
-

Flex node scaffold

-

- Unified Node model (§14) — flex + flex-row nodes rendered by{" "} - NodeRenderer. No rowId/column indices; position = order in{" "} - children[]. -

+ {/* ── Toolbar ── */} +
+
+

Flex node scaffold

+

+ Node model §14 — flex + flex-row via{" "} + EditableNodeRenderer. Toggle edit to drag-reorder. +

+
+ +
- {page ? ( - - ) : ( -

Bootstrapping…

+ {/* ── Edit mode indicator strip ── */} + {isEditMode && ( +
+ Edit mode — drag rows or cards to reorder + {selectedNodeId && ( + + Selected: {selectedNodeId} + + )} +
)} + {/* ── Canvas + properties panel ── */} +
+
+ {page ? ( + + ) : ( +

Bootstrapping…

+ )} +
+ +
+ + {/* ── Raw tree inspector ── */}
Node tree (raw) @@ -101,3 +141,13 @@ export function FlexNodeDemoPage() {
); } + +// ─── Page component (provides EditorContext) ────────────────────────────────── + +export function FlexNodeDemoPage() { + return ( + + + + ); +} diff --git a/packages/ui-next/src/store/useLayoutStore.ts b/packages/ui-next/src/store/useLayoutStore.ts index fe59b792..17b3b940 100644 --- a/packages/ui-next/src/store/useLayoutStore.ts +++ b/packages/ui-next/src/store/useLayoutStore.ts @@ -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>): void; + /** Shallow-merge layout hints onto a node. */ + updateNodeLayout(pageId: string, nodeId: string, layout: Partial): 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() } } }; + }); + }, })); } diff --git a/packages/ui-next/src/widgets-editor/EditableNodeRenderer.tsx b/packages/ui-next/src/widgets-editor/EditableNodeRenderer.tsx new file mode 100644 index 00000000..cedb3409 --- /dev/null +++ b/packages/ui-next/src/widgets-editor/EditableNodeRenderer.tsx @@ -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 ( + + + {node.children.map((child) => renderChild(child))} + + + ); +} + +// ─── 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) => { + updateNodeProps(pageId, node.id, partial); + }, + [pageId, node.id, updateNodeProps], + ); + + // View mode — delegate entirely to the read-only renderer + if (!isEditMode) { + return ; + } + + 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), + 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 ( + }> + + + ); + } + + // ── Container widget (flex, flex-row, …) ──────────────────────────────────── + const Component = def.component; + + const sortStrategy: "vertical" | "horizontal" = + node.type === "flex" ? "vertical" : "horizontal"; + + const renderChild = (child: LayoutNode) => ( + + {(dragHandleProps) => ( + removeNode(pageId, child.id)} + > + + + )} + + ); + + const editChildren = canNest ? ( + + ) : null; + + return ( + + {editChildren} + + ); +} diff --git a/packages/ui-next/src/widgets-editor/EditorShell.tsx b/packages/ui-next/src/widgets-editor/EditorShell.tsx new file mode 100644 index 00000000..92071bac --- /dev/null +++ b/packages/ui-next/src/widgets-editor/EditorShell.tsx @@ -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"; diff --git a/packages/ui-next/src/widgets-editor/components/EditToggle.tsx b/packages/ui-next/src/widgets-editor/components/EditToggle.tsx new file mode 100644 index 00000000..3e55ec2b --- /dev/null +++ b/packages/ui-next/src/widgets-editor/components/EditToggle.tsx @@ -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 ( +
+ + + {isEditMode && ( + + )} +
+ ); +} diff --git a/packages/ui-next/src/widgets-editor/components/NodeEditorOverlay.tsx b/packages/ui-next/src/widgets-editor/components/NodeEditorOverlay.tsx new file mode 100644 index 00000000..6dbbc1b3 --- /dev/null +++ b/packages/ui-next/src/widgets-editor/components/NodeEditorOverlay.tsx @@ -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 = { + 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; + /** 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 ( +
+ {/* Floating header bar — visible on hover/selected */} +
+ {/* Drag grip */} + {dragHandleProps && ( + + ⠿ + + )} + + {/* Type badge */} + + {nodeType} + + + {/* Delete */} + {onDelete && ( + + )} +
+ + {/* Hover glow when NOT selected */} + +
+ {children} +
+
+ ); +} diff --git a/packages/ui-next/src/widgets-editor/components/NodePropertiesForm.tsx b/packages/ui-next/src/widgets-editor/components/NodePropertiesForm.tsx new file mode 100644 index 00000000..852af80e --- /dev/null +++ b/packages/ui-next/src/widgets-editor/components/NodePropertiesForm.tsx @@ -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 ( + 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 ( +
+ {config.label} + {children} + {config.description &&

{config.description}

} +
+ ); +} + +function renderField( + fieldKey: string, + config: ConfigField, + value: unknown, + onChange: (v: unknown) => void, +): React.ReactNode { + const id = `field-${fieldKey}`; + + switch (config.type) { + case "text": + return ( + + + + ); + + case "number": + return ( + + onChange(Number(v) || (config.default as number) || 0)} + min={config.min as number | undefined} + max={config.max as number | undefined} + /> + + ); + + case "boolean": + return ( + + + + ); + + case "select": { + const options = (config.options as Array<{ value: string; label: string }>) ?? []; + return ( + + + + ); + } + + case "markdown": + return ( + +