ui:next widgets1/2
This commit is contained in:
parent
0626be2cf6
commit
5f75ee5bb1
70
packages/ui-next/package-lock.json
generated
70
packages/ui-next/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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() } } };
|
||||
});
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
172
packages/ui-next/src/widgets-editor/EditableNodeRenderer.tsx
Normal file
172
packages/ui-next/src/widgets-editor/EditableNodeRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
packages/ui-next/src/widgets-editor/EditorShell.tsx
Normal file
12
packages/ui-next/src/widgets-editor/EditorShell.tsx
Normal 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";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
16
packages/ui-next/src/widgets-editor/index.ts
Normal file
16
packages/ui-next/src/widgets-editor/index.ts
Normal 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";
|
||||
101
packages/ui-next/src/widgets-editor/lazy.tsx
Normal file
101
packages/ui-next/src/widgets-editor/lazy.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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 }}>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user