252 lines
12 KiB
Markdown
252 lines
12 KiB
Markdown
# Nested Containers — Edit-Mode Architecture
|
|
|
|
How widgets that embed their own canvas (`TabsWidget`, `LayoutContainerWidget`) participate in the editor's data flow.
|
|
|
|
---
|
|
|
|
## Terminology
|
|
|
|
| Term | Description |
|
|
|---|---|
|
|
| **Parent page** | The main page being edited. Layout stored in `loadedPages` under `page-<uuid>`. |
|
|
| **Nested canvas** | A `GenericCanvas` rendered *inside* a widget, identified by a synthetic `pageId` (e.g., `tab-layout-1`, `nested-layout-xyz`). |
|
|
| **Widget props** | The JSON blob stored on the parent page's `WidgetInstance.props` — includes embedded `layoutData`. |
|
|
| **`loadedPages`** | In-memory `Map<string, PageLayout>` managed by `LayoutContext`. Holds layouts for both the parent page and every active nested canvas. |
|
|
|
|
---
|
|
|
|
## Data Flow Overview
|
|
|
|
```
|
|
┌──────────────────────────────────────────────────────────────────┐
|
|
│ UserPageEdit │
|
|
│ page-<uuid> in loadedPages │
|
|
│ └─ containers[] → widgets[] → TabsWidget (widgetId: "w1") │
|
|
│ props.tabs[0].layoutData ← embedded PageLayout │
|
|
│ props.tabs[0].layoutId = "tab-layout-1" │
|
|
└──────┬───────────────────────────────────────────────────────────┘
|
|
│
|
|
▼ GenericCanvas receives initialLayout = tabs[0].layoutData
|
|
┌──────────────────────────────────────────────────────────────────┐
|
|
│ GenericCanvasEdit (pageId = "tab-layout-1") │
|
|
│ Hydration: loadedPages.set("tab-layout-1", initialLayout) │
|
|
│ User edits → Commands against "tab-layout-1" in loadedPages │
|
|
└──────┬───────────────────────────────────────────────────────────┘
|
|
│
|
|
▼ Sync effect writes back
|
|
┌──────────────────────────────────────────────────────────────────┐
|
|
│ TabsWidget sync effect │
|
|
│ Reads loadedPages.get("tab-layout-1") │
|
|
│ Calls onPropsChange({ tabs: [...updated layoutData...] }) │
|
|
└──────┬───────────────────────────────────────────────────────────┘
|
|
│
|
|
▼ handlePropsChange in LayoutContainerEdit
|
|
┌──────────────────────────────────────────────────────────────────┐
|
|
│ updateWidgetProps(parentPageId, "w1", { tabs: [...] }) │
|
|
│ → UpdateWidgetSettingsCommand against parent page │
|
|
│ → parent page layout in loadedPages updated │
|
|
│ → HistoryManager tracks command (undo/redo) │
|
|
└──────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## 1. Hydration — Loading the Nested Layout
|
|
|
|
**File:** [`GenericCanvasEdit.tsx`](file:///c:/Users/zx/Desktop/polymech/pm-pics/src/modules/layout/GenericCanvasEdit.tsx#L72-L89)
|
|
|
|
When a nested widget mounts in edit mode, `GenericCanvasEdit` receives `initialLayout` (the embedded `layoutData` from the widget's props) and a synthetic `pageId`.
|
|
|
|
```ts
|
|
useEffect(() => {
|
|
// First visit: copy prop into loadedPages
|
|
if (initialLayout && !layout) {
|
|
hydratePageLayout(pageId, initialLayout);
|
|
return;
|
|
}
|
|
// Staleness check: re-hydrate if prop is newer
|
|
if (initialLayout && layout
|
|
&& initialLayout.updatedAt > layout.updatedAt) {
|
|
hydratePageLayout(pageId, initialLayout);
|
|
return;
|
|
}
|
|
// Fallback: load from API (not used for embedded layouts)
|
|
if (!layout) {
|
|
loadPageLayout(pageId, pageName);
|
|
}
|
|
}, [pageId, pageName, layout, loadPageLayout, hydratePageLayout, initialLayout]);
|
|
```
|
|
|
|
**Key points:**
|
|
- `hydratePageLayout` is a simple `setLoadedPages(prev => new Map(prev).set(pageId, layout))` — no API call.
|
|
- The staleness check (comparing `updatedAt`) ensures that if the parent page is reloaded or the prop changes externally, the cached layout is replaced.
|
|
|
|
---
|
|
|
|
## 2. Editing — Commands Against the Nested Layout
|
|
|
|
All editor operations (add widget, move, remove, update settings) go through `LayoutContext` functions like `addWidgetToPage`, `updateWidgetProps`, etc. Each creates a `Command` (see [`commands.ts`](file:///c:/Users/zx/Desktop/polymech/pm-pics/src/modules/layout/commands.ts)) and executes it via `HistoryManager`.
|
|
|
|
Commands operate on `loadedPages` by `pageId`. For a nested canvas, the `pageId` is the synthetic ID (e.g., `tab-layout-1`), so commands target the nested layout directly:
|
|
|
|
```ts
|
|
// Adding a widget to a tab canvas:
|
|
addWidgetToPage("tab-layout-1", containerId, widgetInstance)
|
|
// → AddWidgetCommand reads loadedPages.get("tab-layout-1")
|
|
// → Modifies it, calls context.updateLayout("tab-layout-1", newLayout)
|
|
```
|
|
|
|
**This is fully tracked by undo/redo** — `HistoryManager` stores the command in its `past` stack.
|
|
|
|
---
|
|
|
|
## 3. Sync-Back — Writing Nested Changes to Parent Props
|
|
|
|
The nested layout edits live in `loadedPages["tab-layout-1"]`, but the parent page's save function only persists `loadedPages["page-<uuid>"]`. So the nested data must be **synced back** as widget props on the parent.
|
|
|
|
### TabsWidget
|
|
|
|
**File:** [`TabsWidget.tsx`](file:///c:/Users/zx/Desktop/polymech/pm-pics/src/components/widgets/TabsWidget.tsx#L100-L122)
|
|
|
|
```ts
|
|
useEffect(() => {
|
|
if (!isEditMode) return;
|
|
let changed = false;
|
|
const newTabs = tabs.map(t => {
|
|
const layout = loadedPages.get(t.layoutId);
|
|
if (layout) {
|
|
const propTimestamp = t.layoutData?.updatedAt || 0;
|
|
if (layout.updatedAt > propTimestamp) {
|
|
const layoutChanged =
|
|
JSON.stringify(layout) !== JSON.stringify(t.layoutData);
|
|
if (layoutChanged) {
|
|
changed = true;
|
|
return { ...t, layoutData: layout };
|
|
}
|
|
}
|
|
}
|
|
return t;
|
|
});
|
|
if (changed) {
|
|
onPropsChange({ tabs: newTabs });
|
|
}
|
|
}, [loadedPages, isEditMode, onPropsChange, tabs]);
|
|
```
|
|
|
|
**Important:** Iterates **all** tabs, not just the current one. This ensures that if you edit Tab A then switch to Tab B, Tab A's changes are still written back before save.
|
|
|
|
### LayoutContainerWidget
|
|
|
|
**File:** [`LayoutContainerWidget.tsx`](file:///c:/Users/zx/Desktop/polymech/pm-pics/src/components/widgets/LayoutContainerWidget.tsx#L56-L70)
|
|
|
|
Same pattern but simpler — only one nested layout:
|
|
|
|
```ts
|
|
useEffect(() => {
|
|
if (uniqueNestedPageId && isEditMode) {
|
|
const currentLayout = loadedPages.get(uniqueNestedPageId);
|
|
if (currentLayout) {
|
|
const propTimestamp = nestedLayoutData?.updatedAt || 0;
|
|
if (currentLayout.updatedAt > propTimestamp) {
|
|
const layoutChanged =
|
|
JSON.stringify(currentLayout) !== JSON.stringify(nestedLayoutData);
|
|
if (layoutChanged) {
|
|
onPropsChange({ nestedLayoutData: currentLayout });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}, [uniqueNestedPageId, loadedPages, isEditMode, onPropsChange, nestedLayoutData]);
|
|
```
|
|
|
|
### onPropsChange → updateWidgetProps → Command
|
|
|
|
**File:** [`LayoutContainerEdit.tsx`](file:///c:/Users/zx/Desktop/polymech/pm-pics/src/modules/layout/LayoutContainerEdit.tsx#L621-L627)
|
|
|
|
When `onPropsChange` is called, it flows to:
|
|
|
|
```ts
|
|
const handlePropsChange = async (newProps) => {
|
|
await updateWidgetProps(pageId, widget.id, newProps);
|
|
};
|
|
```
|
|
|
|
`updateWidgetProps` creates an [`UpdateWidgetSettingsCommand`](file:///c:/Users/zx/Desktop/polymech/pm-pics/src/modules/layout/commands.ts#L184-L261) against the **parent page's** `pageId`. This:
|
|
- Snapshots the old `widget.props` for undo
|
|
- Merges `newProps` into `widget.props`
|
|
- Updates `loadedPages[parentPageId]`
|
|
- Pushes to `HistoryManager.past` (undoable)
|
|
|
|
---
|
|
|
|
## 4. Saving — Persistence Path
|
|
|
|
**File:** [`usePageEditHandlers.ts`](file:///c:/Users/zx/Desktop/polymech/pm-pics/src/modules/pages/editor/hooks/usePageEditHandlers.ts#L187-L226)
|
|
|
|
```ts
|
|
const handleSave = async () => {
|
|
loadedPages.forEach((layout, id) => {
|
|
if (id.startsWith('page-')) {
|
|
// Save as page content
|
|
promises.push(updatePage(pId, { content: rootContent }));
|
|
} else if (id.startsWith('layout-')) {
|
|
// Save as standalone layout
|
|
promises.push(updateLayout(layoutId, { layout_json: layout }));
|
|
}
|
|
// IDs like "tab-layout-1" or "nested-layout-xyz"
|
|
// are INTENTIONALLY SKIPPED — their data lives inside
|
|
// the parent page's widget props (already synced by step 3)
|
|
});
|
|
};
|
|
```
|
|
|
|
**The nested layouts are NOT saved independently.** They are saved **embedded** within the parent page's widget props. This is correct because:
|
|
|
|
1. Sync effect (step 3) copies `loadedPages["tab-layout-1"]` → `widget.props.tabs[0].layoutData`
|
|
2. This updates `loadedPages["page-<uuid>"]` via `UpdateWidgetSettingsCommand`
|
|
3. `handleSave` writes `loadedPages["page-<uuid>"]` to the API
|
|
4. On reload, `widget.props.tabs[0].layoutData` is passed as `initialLayout`
|
|
|
|
---
|
|
|
|
## 5. Undo/Redo
|
|
|
|
All operations produce `Command` objects tracked by [`HistoryManager`](file:///c:/Users/zx/Desktop/polymech/pm-pics/src/modules/layout/HistoryManager.ts):
|
|
|
|
| Action | Command | Target `pageId` |
|
|
|---|---|---|
|
|
| Add widget to tab | `AddWidgetCommand` | `tab-layout-1` |
|
|
| Move widget in tab | `MoveWidgetCommand` | `tab-layout-1` |
|
|
| Sync tab data to parent | `UpdateWidgetSettingsCommand` | `page-<uuid>` |
|
|
|
|
**Undo** pops the last command from `past` and calls `command.undo(context)`. Because syncs also produce commands, undoing a sync restores the previous `tabs` array on the parent.
|
|
|
|
> **Note:** An undo of the sync command reverts the tab data at the *parent* level but doesn't automatically revert the *nested* layout in `loadedPages["tab-layout-1"]`. These are two separate entries. In practice, the user undoes the inner edit (which reverts the nested layout), and the next sync cycle propagates that reversion to the parent.
|
|
|
|
---
|
|
|
|
## 6. ID Conventions
|
|
|
|
| Prefix | Source | Saved by |
|
|
|---|---|---|
|
|
| `page-<uuid>` | Real page from database | `updatePage()` API |
|
|
| `layout-<uuid>` | Standalone reusable layout | `updateLayout()` API |
|
|
| `tab-layout-<N>` | TabsWidget tab | Embedded in parent page's widget props |
|
|
| `nested-layout-<widgetInstanceId>` | LayoutContainerWidget | Embedded in parent page's widget props |
|
|
|
|
---
|
|
|
|
## 7. Known Pitfalls
|
|
|
|
### Sync must cover all tabs
|
|
The sync effect must iterate **all** tabs, not just the active tab. Otherwise, switching tabs before saving drops the inactive tab's edits. (Fixed March 2026.)
|
|
|
|
### Re-hydration staleness
|
|
When `GenericCanvasEdit` detects that `initialLayout.updatedAt > layout.updatedAt`, it re-hydrates from the prop. Without this check, a reloaded page (with updated server data) would render stale cached data from a previous session.
|
|
|
|
### JSON.stringify comparison cost
|
|
Both `TabsWidget` and `LayoutContainerWidget` use `JSON.stringify` comparison as a safety check before calling `onPropsChange`. For very large layouts this could be slow — but it prevents infinite re-render loops where the timestamp guard alone isn't sufficient (e.g., when timestamps match but content differs due to manual edits).
|
|
|
|
### Nested nesting
|
|
`TabsWidget` and `LayoutContainerWidget` can be nested inside each other. Each level adds another entry to `loadedPages` and another sync effect. The same sync/hydration pattern applies recursively — each widget syncs its own nested layout back to its parent's props.
|